diff --git a/.phpcs.xml b/.phpcs.xml new file mode 100644 index 0000000..7147758 --- /dev/null +++ b/.phpcs.xml @@ -0,0 +1,47 @@ + + + PSR-12 coding standard for NextBridge RoundCube plugin + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ident_switch.php + lib/ + + + */vendor/* + diff --git a/composer.json b/composer.json index 981abf5..de448b9 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,9 @@ "roundcube/plugin-installer": ">=0.3.0", "ext-ctype": "*" }, + "require-dev": { + "squizlabs/php_codesniffer": "^3.0" + }, "conflict": { "boressoft/ident_switch": "*", "elm/identity_smtp": "*", @@ -51,5 +54,14 @@ "min-version": "1.6", "sql-dir": "SQL" } + }, + "scripts": { + "phpcs": "phpcs", + "phpcbf": "phpcbf" + }, + "config": { + "allow-plugins": { + "roundcube/plugin-installer": true + } } } diff --git a/composer.lock b/composer.lock new file mode 100644 index 0000000..9a26441 --- /dev/null +++ b/composer.lock @@ -0,0 +1,158 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "46b2d3558cb304fde0539d1b85971382", + "packages": [ + { + "name": "roundcube/plugin-installer", + "version": "0.3.2", + "source": { + "type": "git", + "url": "https://github.com/roundcube/plugin-installer.git", + "reference": "c4335e20b86cfe3a184ccf24d675c6a0338a372a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/roundcube/plugin-installer/zipball/c4335e20b86cfe3a184ccf24d675c6a0338a372a", + "reference": "c4335e20b86cfe3a184ccf24d675c6a0338a372a", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0 || ^2.0" + }, + "require-dev": { + "composer/composer": "*" + }, + "type": "composer-plugin", + "extra": { + "class": [ + "Roundcube\\Composer\\RoundcubeInstaller" + ] + }, + "autoload": { + "psr-0": { + "Roundcube\\Composer": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-3.0+" + ], + "authors": [ + { + "name": "Thomas Bruederli", + "email": "thomas@roundcube.net" + }, + { + "name": "Till Klampaeckel", + "email": "till@php.net" + }, + { + "name": "Philip Weir", + "email": "roundcube@tehinterweb.co.uk" + } + ], + "description": "A composer-installer for Roundcube plugins and skins.", + "support": { + "issues": "https://github.com/roundcube/plugin-installer/issues", + "source": "https://github.com/roundcube/plugin-installer/tree/0.3.2" + }, + "time": "2022-06-24T09:08:18+00:00" + } + ], + "packages-dev": [ + { + "name": "squizlabs/php_codesniffer", + "version": "3.13.5", + "source": { + "type": "git", + "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4", + "shasum": "" + }, + "require": { + "ext-simplexml": "*", + "ext-tokenizer": "*", + "ext-xmlwriter": "*", + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4" + }, + "bin": [ + "bin/phpcbf", + "bin/phpcs" + ], + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Greg Sherwood", + "role": "Former lead" + }, + { + "name": "Juliette Reinders Folmer", + "role": "Current lead" + }, + { + "name": "Contributors", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors" + } + ], + "description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.", + "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "keywords": [ + "phpcs", + "standards", + "static analysis" + ], + "support": { + "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues", + "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy", + "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer", + "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki" + }, + "funding": [ + { + "url": "https://github.com/PHPCSStandards", + "type": "github" + }, + { + "url": "https://github.com/jrfnl", + "type": "github" + }, + { + "url": "https://opencollective.com/php_codesniffer", + "type": "open_collective" + }, + { + "url": "https://thanks.dev/u/gh/phpcsstandards", + "type": "thanks_dev" + } + ], + "time": "2025-11-04T16:30:35+00:00" + } + ], + "aliases": [], + "minimum-stability": "stable", + "stability-flags": {}, + "prefer-stable": false, + "prefer-lowest": false, + "platform": { + "php": ">=8.2", + "ext-ctype": "*" + }, + "platform-dev": {}, + "plugin-api-version": "2.6.0" +} diff --git a/ident_switch.php b/ident_switch.php index 882e91f..dce1c37 100644 --- a/ident_switch.php +++ b/ident_switch.php @@ -1,4 +1,5 @@ form = new IdentSwitchForm($this); - $this->switcher = new IdentSwitchSwitcher(); - $this->preconfig = new IdentSwitchPreconfig($this); - $this->checker = new IdentSwitchChecker(); - - $this->add_hook('startup', [$this, 'on_startup']); - $this->add_hook('render_page', [$this, 'on_render_page']); - $this->add_hook('refresh', [$this, 'on_refresh']); - $this->add_hook('smtp_connect', [$this, 'on_smtp_connect']); - $this->add_hook('managesieve_connect', [$this, 'on_managesieve_connect']); - $this->add_hook('identity_form', [$this, 'on_identity_form']); - $this->add_hook('identity_update', [$this, 'on_identity_update']); - $this->add_hook('identity_create', [$this, 'on_identity_create']); - $this->add_hook('identity_create_after', [$this, 'on_identity_create_after']); - $this->add_hook('identity_delete', [$this, 'on_identity_delete']); - $this->add_hook('template_object_composeheaders', [$this, 'on_template_object_composeheaders']); - $this->add_hook('preferences_list', [$this, 'on_special_folders_form']); - $this->add_hook('preferences_save', [$this, 'on_special_folders_update']); - - $this->register_action('plugin.ident_switch.switch', [$this, 'on_switch']); - - $this->load_config(); - - $rc = rcmail::get_instance(); - foreach (rcube_storage::$folder_types as $type) { - $key = $type . '_mbox_default' . self::MY_POSTFIX; - if (empty($_SESSION[$key])) { - $_SESSION[$key] = $rc->config->get($type . '_mbox'); - } - } - } - - /** - * Handle startup hook: detect impersonation, disable caches, restore folder config. - * - * @param array $args Hook arguments containing 'task' and other startup data. - * @return array Modified hook arguments. - */ - public function on_startup(array $args): array - { - $rc = rcmail::get_instance(); - - if (strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0) { - // We are impersonating - $rc->config->set('imap_cache', null); - $rc->config->set('messages_cache', false); - - if ($args['task'] === 'mail') { - $this->add_texts('localization/'); - $rc->config->set('create_default_folders', false); - } - } - - foreach (rcube_storage::$folder_types as $type) { - $defaultKey = $type . '_mbox_default' . self::MY_POSTFIX; - $otherKey = $type . '_mbox' . self::MY_POSTFIX; - $val = $_SESSION[$otherKey] ?? $_SESSION[$defaultKey]; - $rc->config->set($type . '_mbox', $val); - } - - return $args; - } - - /** - * Handle render_page hook: inject account switcher or settings form script. - * - * @param array $args Hook arguments containing page rendering data. - * @return array Modified hook arguments. - */ - public function on_render_page(array $args): array - { - $rc = rcmail::get_instance(); - - match ($rc->task) { - 'mail' => $this->render_switch($rc, $args), - 'settings' => $this->include_script('ident_switch-form.js'), - default => null, - }; - - if ($rc->task === 'mail') { - $this->include_stylesheet('ident_switch.css'); - } - - return $args; - } - - /** - * Render the account switcher dropdown in the mail view. - * - * Queries the database for all enabled alternative accounts and generates - * an HTML select element with an unread badge, injected into the page footer. - * - * @param rcmail $rc Roundcube instance. - * @param array $args Hook arguments for page rendering. - */ - private function render_switch(rcmail $rc, array $args): void - { - // Currently selected identity - $iid_s = $_SESSION['iid' . self::MY_POSTFIX] ?? null; - - $iid = 0; - if (is_int($iid_s)) { - $iid = $iid_s; - } elseif ($iid_s === '-1') { - $iid = -1; - } elseif (is_string($iid_s) && ctype_digit($iid_s)) { - $iid = intval($iid_s); - } - - $accNames = [$_SESSION['global_alias'] ?? $rc->user->data['username']]; - $accValues = [-1]; - $accSelected = -1; - $iidMap = [0 => -1]; // primary account: iid 0 → select value -1 - - // Get list of alternative accounts - $sql = "SELECT " - . "isw.id, isw.iid, isw.label, isw.username, ii.email" - . " FROM" - . " {$rc->db->table_name(self::TABLE)} isw" - . " INNER JOIN {$rc->db->table_name('identities')} ii ON isw.iid=ii.identity_id" - . " WHERE isw.user_id = ? AND isw.flags & ? > 0 AND isw.parent_id IS NULL"; - $qRec = $rc->db->query($sql, $rc->user->data['user_id'], self::DB_ENABLED); - while ($r = $rc->db->fetch_assoc($qRec)) { - $accValues[] = $r['id']; - $iidMap[$r['iid']] = $r['id']; - if ($iid === (int)$r['iid']) { - $accSelected = $r['id']; - } - - $lbl = $r['label'] ?: $r['username'] ?: $r['email']; - $accNames[] = rcube::Q($lbl); - } - - if (count($accValues) <= 1) { - return; - } - - $this->include_script('ident_switch-switch.js'); - - // Pass config to JS environment - $rc->output->set_env('ident_switch_iid_map', $iidMap); - - $select = new html_select([ - 'id' => 'plugin-ident_switch-account', - 'style' => 'display: none; padding: 0;', - 'onchange' => 'plugin_switchIdent_switch(this.value);', - ]); - $select->add($accNames, $accValues); - - $html = '' - . $select->show([$accSelected]) - . '' - . ''; - - $rc->output->add_footer($html); - - if (!$rc->config->get('ident_switch.check_mail', true)) { - return; - } - - // Run initial check and pass counts via env - $this->checker->check_new_mail([]); - $counts = $_SESSION['ident_switch_counts'] ?? []; - $initialCounts = []; - foreach ($counts as $cIid => $info) { - $initialCounts[$cIid] = [ - 'unseen' => $info['unseen'], - 'baseline' => $info['baseline'] ?? $info['unseen'], - ]; - } - $rc->output->set_env('ident_switch_initial_counts', $initialCounts); - } - - /** - * Handle refresh hook: check new mail on secondary identities. - * - * @param array $args Hook arguments (empty for refresh). - * @return array Unmodified hook arguments. - */ - public function on_refresh(array $args): array - { - $rc = rcmail::get_instance(); - if (!$rc->config->get('ident_switch.check_mail', true)) { - return $args; - } - - return $this->checker->check_new_mail($args); - } - - /** - * Handle smtp_connect hook: configure SMTP settings for the active account. - * - * @param array $args Hook arguments containing SMTP connection parameters. - * @return array Modified hook arguments with updated SMTP settings. - */ - public function on_smtp_connect(array $args): array - { - return $this->switcher->configure_smtp($args); - } - - /** - * Handle managesieve_connect hook: configure Sieve settings for the active account. - * - * @param array $args Hook arguments containing Sieve connection parameters. - * @return array Modified hook arguments with updated Sieve settings. - */ - public function on_managesieve_connect(array $args): array - { - return $this->switcher->configure_managesieve($args); - } - - /** - * Handle identity_form hook: add plugin-specific fields to the identity editor. - * - * @param array $args Hook arguments containing 'record' with identity data. - * @return array Modified hook arguments with added form sections. - */ - public function on_identity_form(array $args): array - { - return $this->form->on_identity_form($args, $this->preconfig); - } - - /** - * Handle identity_update hook: validate and save plugin fields on identity edit. - * - * @param array $args Hook arguments containing 'id' and 'record' with identity data. - * @return array Modified hook arguments, with 'abort' set on validation failure. - */ - public function on_identity_update(array $args): array - { - return $this->form->on_identity_update($args); - } - - /** - * Handle identity_create hook: validate plugin fields before identity creation. - * - * @param array $args Hook arguments containing 'record' with identity data. - * @return array Modified hook arguments, with 'abort' set on validation failure. - */ - public function on_identity_create(array $args): array - { - return $this->form->on_identity_create($args); - } - - /** - * Handle identity_create_after hook: persist plugin data after identity creation. - * - * @param array $args Hook arguments containing 'id' (new identity_id) and 'record'. - * @return array Unmodified hook arguments. - */ - public function on_identity_create_after(array $args): array - { - return $this->form->on_identity_create_after($args); - } - - /** - * Handle identity_delete hook: remove plugin data when an identity is deleted. - * - * @param array $args Hook arguments containing 'id' of the identity being deleted. - * @return array Unmodified hook arguments. - */ - public function on_identity_delete(array $args): array - { - return $this->form->on_identity_delete($args); - } - - /** - * Handle template_object_composeheaders hook: filter and fix identity selection. - * - * Restricts the From dropdown to identities belonging to the active account - * (the account itself + its aliases). When impersonating, also pre-selects - * the correct identity. - * - * @param array $args Hook arguments containing form element 'id'. - */ - public function on_template_object_composeheaders(array $args): void - { - if ($args['id'] !== '_from') { - return; - } - - $rc = rcmail::get_instance(); - $userId = $rc->user->ID; - $isImpersonating = strcasecmp($_SESSION['username'], $rc->user->data['username']) !== 0; - - // Pre-select the active identity when impersonating - if ($isImpersonating) { - if (isset($_SESSION['iid' . self::MY_POSTFIX])) { - $iid = $_SESSION['iid' . self::MY_POSTFIX]; - $rc->output->add_script("plugin_switchIdent_fixIdent({$iid});", 'docready'); - } else { - self::write_log('Special session variable with active identity ID not found.'); - } - } - - // Compute allowed identity IDs for the From dropdown - $iidSession = $_SESSION['iid' . self::MY_POSTFIX] ?? null; - - if ($isImpersonating && is_numeric($iidSession) && (int)$iidSession > 0) { - // Switched to a secondary account: allow its identity + aliases - $sql = 'SELECT id FROM ' . $rc->db->table_name(self::TABLE) - . ' WHERE iid = ? AND user_id = ? AND parent_id IS NULL'; - $q = $rc->db->query($sql, (int)$iidSession, $userId); - $r = $rc->db->fetch_assoc($q); - - if ($r) { - $accountId = (int)$r['id']; - $sql = 'SELECT iid FROM ' . $rc->db->table_name(self::TABLE) - . ' WHERE (id = ? OR parent_id = ?) AND user_id = ? AND flags & ? > 0'; - $q = $rc->db->query($sql, $accountId, $accountId, $userId, self::DB_ENABLED); - $allowed = []; - while ($row = $rc->db->fetch_assoc($q)) { - $allowed[] = (int)$row['iid']; - } - $rc->output->set_env('ident_switch_allowed_identities', $allowed); - } - } else { - // Primary account: exclude identities belonging to enabled accounts/aliases - $sql = 'SELECT iid FROM ' . $rc->db->table_name(self::TABLE) - . ' WHERE user_id = ? AND flags & ? > 0'; - $q = $rc->db->query($sql, $userId, self::DB_ENABLED); - $excluded = []; - while ($row = $rc->db->fetch_assoc($q)) { - $excluded[] = (int)$row['iid']; - } - - if (!empty($excluded)) { - $sql = 'SELECT identity_id FROM ' . $rc->db->table_name('identities') - . ' WHERE user_id = ? AND del = 0'; - $q = $rc->db->query($sql, $userId); - $allowed = []; - while ($row = $rc->db->fetch_assoc($q)) { - $iid = (int)$row['identity_id']; - if (!in_array($iid, $excluded)) { - $allowed[] = $iid; - } - } - $rc->output->set_env('ident_switch_allowed_identities', $allowed); - } - } - } - - /** - * Handle preferences_list hook: customize special folders form for remote accounts. - * - * @param array $args Hook arguments containing 'section' and 'blocks' with form data. - * @return array Modified hook arguments with updated folder selections. - */ - public function on_special_folders_form(array $args): array - { - return $this->switcher->get_special_folders_form($args); - } - - /** - * Handle preferences_save hook: persist special folder assignments for remote accounts. - * - * @param array $args Hook arguments containing 'section' and 'prefs' with folder data. - * @return array Modified hook arguments, with 'abort' set to prevent default save. - */ - public function on_special_folders_update(array $args): array - { - return $this->switcher->save_special_folders($args); - } - - /** - * Handle the account switch action (AJAX). - */ - public function on_switch(): void - { - $this->switcher->switch_account(); - } - - /** - * Trim a string, returning null if the result is empty. - * - * @param string|null $str Input string. - * @return string|null Trimmed string or null if empty. - */ - public static function ntrim(?string $str): ?string - { - if ($str === null) { - return null; - } - - $s = trim($str); - return $s !== '' ? $s : null; - } - - /** - * Parse scheme prefix (ssl://, tls://) from a host string. - * - * @param string $host Host string, optionally prefixed with ssl:// or tls://. - * @return array{scheme: string, host: string} Parsed scheme and bare host. - */ - public static function parse_host_scheme(string $host): array - { - $lower = strtolower($host); - if (str_starts_with($lower, 'ssl://')) { - return ['scheme' => 'ssl', 'host' => substr($host, 6)]; - } - if (str_starts_with($lower, 'tls://')) { - return ['scheme' => 'tls', 'host' => substr($host, 6)]; - } - return ['scheme' => '', 'host' => $host]; - } - - /** - * Resolve username for an identity: use stored username or fall back to email. - * - * @param int $iid Identity ID. - * @param string|null $username Stored username (may be empty). - * @return string Resolved username. - */ - public static function resolve_username(int $iid, ?string $username): string - { - if (!empty($username)) { - return $username; - } - - $rc = rcmail::get_instance(); - $sql = 'SELECT email FROM ' . $rc->db->table_name('identities') . ' WHERE identity_id = ?'; - $q = $rc->db->query($sql, $iid); - $r = $rc->db->fetch_assoc($q); - return $r['email'] ?? ''; - } - - /** - * Write a message to the plugin's log file. - * - * @param string $txt Log message. - */ - public static function write_log(string $txt): void - { - rcmail::get_instance()->write_log('ident_switch', $txt); - } - - /** - * Write a debug message (only when ident_switch.debug is enabled). - * - * @param string $txt Log message. - */ - public static function debug_log(string $txt): void - { - if (rcmail::get_instance()->config->get('ident_switch.debug', false)) { - rcmail::get_instance()->write_log('ident_switch', '[DEBUG] ' . $txt); - } - } + /** @var string Task regex: active on all tasks except login/logout. */ + public $task = '?(?!login|logout).*'; + + /** @var string Database table name for this plugin. */ + public const TABLE = 'ident_switch'; + + /** @var string Session variable suffix used to store/restore state. */ + public const MY_POSTFIX = '_iswitch'; + + /** @var int Flag: account switching is enabled. */ + public const DB_ENABLED = 1; + + /** @var int Legacy flag: use TLS for IMAP. Read-only for backward compat; new records store scheme in host. */ + public const DB_SECURE_IMAP_TLS = 4; + + /** @var int SMTP authentication: use same credentials as IMAP. */ + public const SMTP_AUTH_IMAP = 1; + + /** @var int SMTP authentication: no authentication required. */ + public const SMTP_AUTH_NONE = 2; + + /** @var int SMTP authentication: use custom credentials. */ + public const SMTP_AUTH_CUSTOM = 3; + + /** @var int Sieve authentication: use same credentials as IMAP. */ + public const SIEVE_AUTH_IMAP = 1; + + /** @var int Sieve authentication: no authentication required. */ + public const SIEVE_AUTH_NONE = 2; + + /** @var int Sieve authentication: use custom credentials. */ + public const SIEVE_AUTH_CUSTOM = 3; + + /** @var int Notification checking: enabled. */ + public const NOTIFY_CHECK_ENABLED = 1; + + /** @var int Notification checking: disabled. */ + public const NOTIFY_CHECK_DISABLED = 0; + + private IdentSwitchForm $form; + private IdentSwitchSwitcher $switcher; + private IdentSwitchPreconfig $preconfig; + private IdentSwitchChecker $checker; + + /** + * Initialize plugin: register hooks, actions, and save default folder config. + */ + public function init(): void + { + $this->form = new IdentSwitchForm($this); + $this->switcher = new IdentSwitchSwitcher(); + $this->preconfig = new IdentSwitchPreconfig($this); + $this->checker = new IdentSwitchChecker(); + + $this->add_hook('startup', [$this, 'on_startup']); + $this->add_hook('render_page', [$this, 'on_render_page']); + $this->add_hook('refresh', [$this, 'on_refresh']); + $this->add_hook('smtp_connect', [$this, 'on_smtp_connect']); + $this->add_hook('managesieve_connect', [$this, 'on_managesieve_connect']); + $this->add_hook('identity_form', [$this, 'on_identity_form']); + $this->add_hook('identity_update', [$this, 'on_identity_update']); + $this->add_hook('identity_create', [$this, 'on_identity_create']); + $this->add_hook('identity_create_after', [$this, 'on_identity_create_after']); + $this->add_hook('identity_delete', [$this, 'on_identity_delete']); + $this->add_hook('template_object_composeheaders', [$this, 'on_template_object_composeheaders']); + $this->add_hook('preferences_list', [$this, 'on_special_folders_form']); + $this->add_hook('preferences_save', [$this, 'on_special_folders_update']); + + $this->register_action('plugin.ident_switch.switch', [$this, 'on_switch']); + + $this->load_config(); + + $rc = rcmail::get_instance(); + foreach (rcube_storage::$folder_types as $type) { + $key = $type . '_mbox_default' . self::MY_POSTFIX; + if (empty($_SESSION[$key])) { + $_SESSION[$key] = $rc->config->get($type . '_mbox'); + } + } + } + + /** + * Handle startup hook: detect impersonation, disable caches, restore folder config. + * + * @param array $args Hook arguments containing 'task' and other startup data. + * @return array Modified hook arguments. + */ + public function on_startup(array $args): array + { + $rc = rcmail::get_instance(); + + if (strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0) { + // We are impersonating + $rc->config->set('imap_cache', null); + $rc->config->set('messages_cache', false); + + if ($args['task'] === 'mail') { + $this->add_texts('localization/'); + $rc->config->set('create_default_folders', false); + } + } + + foreach (rcube_storage::$folder_types as $type) { + $defaultKey = $type . '_mbox_default' . self::MY_POSTFIX; + $otherKey = $type . '_mbox' . self::MY_POSTFIX; + $val = $_SESSION[$otherKey] ?? $_SESSION[$defaultKey]; + $rc->config->set($type . '_mbox', $val); + } + + return $args; + } + + /** + * Handle render_page hook: inject account switcher or settings form script. + * + * @param array $args Hook arguments containing page rendering data. + * @return array Modified hook arguments. + */ + public function on_render_page(array $args): array + { + $rc = rcmail::get_instance(); + + match ($rc->task) { + 'mail' => $this->render_switch($rc, $args), + 'settings' => $this->include_script('ident_switch-form.js'), + default => null, + }; + + if ($rc->task === 'mail') { + $this->include_stylesheet('ident_switch.css'); + } + + return $args; + } + + /** + * Render the account switcher dropdown in the mail view. + * + * Queries the database for all enabled alternative accounts and generates + * an HTML select element with an unread badge, injected into the page footer. + * + * @param rcmail $rc Roundcube instance. + * @param array $args Hook arguments for page rendering. + */ + private function render_switch(rcmail $rc, array $args): void + { + // Currently selected identity + $iid_s = $_SESSION['iid' . self::MY_POSTFIX] ?? null; + + $iid = 0; + if (is_int($iid_s)) { + $iid = $iid_s; + } elseif ($iid_s === '-1') { + $iid = -1; + } elseif (is_string($iid_s) && ctype_digit($iid_s)) { + $iid = intval($iid_s); + } + + $accNames = [$_SESSION['global_alias'] ?? $rc->user->data['username']]; + $accValues = [-1]; + $accSelected = -1; + $iidMap = [0 => -1]; // primary account: iid 0 → select value -1 + + // Get list of alternative accounts + $sql = "SELECT " + . "isw.id, isw.iid, isw.label, isw.username, ii.email" + . " FROM" + . " {$rc->db->table_name(self::TABLE)} isw" + . " INNER JOIN {$rc->db->table_name('identities')} ii ON isw.iid=ii.identity_id" + . " WHERE isw.user_id = ? AND isw.flags & ? > 0 AND isw.parent_id IS NULL"; + $qRec = $rc->db->query($sql, $rc->user->data['user_id'], self::DB_ENABLED); + while ($r = $rc->db->fetch_assoc($qRec)) { + $accValues[] = $r['id']; + $iidMap[$r['iid']] = $r['id']; + if ($iid === (int)$r['iid']) { + $accSelected = $r['id']; + } + + $lbl = $r['label'] ?: $r['username'] ?: $r['email']; + $accNames[] = rcube::Q($lbl); + } + + if (count($accValues) <= 1) { + return; + } + + $this->include_script('ident_switch-switch.js'); + + // Pass config to JS environment + $rc->output->set_env('ident_switch_iid_map', $iidMap); + + $select = new html_select([ + 'id' => 'plugin-ident_switch-account', + 'style' => 'display: none; padding: 0;', + 'onchange' => 'plugin_switchIdent_switch(this.value);', + ]); + $select->add($accNames, $accValues); + + $html = '' + . $select->show([$accSelected]) + . '' + . ''; + + $rc->output->add_footer($html); + + if (!$rc->config->get('ident_switch.check_mail', true)) { + return; + } + + // Run initial check and pass counts via env + $this->checker->check_new_mail([]); + $counts = $_SESSION['ident_switch_counts'] ?? []; + $initialCounts = []; + foreach ($counts as $cIid => $info) { + $initialCounts[$cIid] = [ + 'unseen' => $info['unseen'], + 'baseline' => $info['baseline'] ?? $info['unseen'], + ]; + } + $rc->output->set_env('ident_switch_initial_counts', $initialCounts); + } + + /** + * Handle refresh hook: check new mail on secondary identities. + * + * @param array $args Hook arguments (empty for refresh). + * @return array Unmodified hook arguments. + */ + public function on_refresh(array $args): array + { + $rc = rcmail::get_instance(); + if (!$rc->config->get('ident_switch.check_mail', true)) { + return $args; + } + + return $this->checker->check_new_mail($args); + } + + /** + * Handle smtp_connect hook: configure SMTP settings for the active account. + * + * @param array $args Hook arguments containing SMTP connection parameters. + * @return array Modified hook arguments with updated SMTP settings. + */ + public function on_smtp_connect(array $args): array + { + return $this->switcher->configure_smtp($args); + } + + /** + * Handle managesieve_connect hook: configure Sieve settings for the active account. + * + * @param array $args Hook arguments containing Sieve connection parameters. + * @return array Modified hook arguments with updated Sieve settings. + */ + public function on_managesieve_connect(array $args): array + { + return $this->switcher->configure_managesieve($args); + } + + /** + * Handle identity_form hook: add plugin-specific fields to the identity editor. + * + * @param array $args Hook arguments containing 'record' with identity data. + * @return array Modified hook arguments with added form sections. + */ + public function on_identity_form(array $args): array + { + return $this->form->on_identity_form($args, $this->preconfig); + } + + /** + * Handle identity_update hook: validate and save plugin fields on identity edit. + * + * @param array $args Hook arguments containing 'id' and 'record' with identity data. + * @return array Modified hook arguments, with 'abort' set on validation failure. + */ + public function on_identity_update(array $args): array + { + return $this->form->on_identity_update($args); + } + + /** + * Handle identity_create hook: validate plugin fields before identity creation. + * + * @param array $args Hook arguments containing 'record' with identity data. + * @return array Modified hook arguments, with 'abort' set on validation failure. + */ + public function on_identity_create(array $args): array + { + return $this->form->on_identity_create($args); + } + + /** + * Handle identity_create_after hook: persist plugin data after identity creation. + * + * @param array $args Hook arguments containing 'id' (new identity_id) and 'record'. + * @return array Unmodified hook arguments. + */ + public function on_identity_create_after(array $args): array + { + return $this->form->on_identity_create_after($args); + } + + /** + * Handle identity_delete hook: remove plugin data when an identity is deleted. + * + * @param array $args Hook arguments containing 'id' of the identity being deleted. + * @return array Unmodified hook arguments. + */ + public function on_identity_delete(array $args): array + { + return $this->form->on_identity_delete($args); + } + + /** + * Handle template_object_composeheaders hook: filter and fix identity selection. + * + * Restricts the From dropdown to identities belonging to the active account + * (the account itself + its aliases). When impersonating, also pre-selects + * the correct identity. + * + * @param array $args Hook arguments containing form element 'id'. + */ + public function on_template_object_composeheaders(array $args): void + { + if ($args['id'] !== '_from') { + return; + } + + $rc = rcmail::get_instance(); + $userId = $rc->user->ID; + $isImpersonating = strcasecmp($_SESSION['username'], $rc->user->data['username']) !== 0; + + // Pre-select the active identity when impersonating + if ($isImpersonating) { + if (isset($_SESSION['iid' . self::MY_POSTFIX])) { + $iid = $_SESSION['iid' . self::MY_POSTFIX]; + $rc->output->add_script("plugin_switchIdent_fixIdent({$iid});", 'docready'); + } else { + self::write_log('Special session variable with active identity ID not found.'); + } + } + + // Compute allowed identity IDs for the From dropdown + $iidSession = $_SESSION['iid' . self::MY_POSTFIX] ?? null; + + if ($isImpersonating && is_numeric($iidSession) && (int)$iidSession > 0) { + // Switched to a secondary account: allow its identity + aliases + $sql = 'SELECT id FROM ' . $rc->db->table_name(self::TABLE) + . ' WHERE iid = ? AND user_id = ? AND parent_id IS NULL'; + $q = $rc->db->query($sql, (int)$iidSession, $userId); + $r = $rc->db->fetch_assoc($q); + + if ($r) { + $accountId = (int)$r['id']; + $sql = 'SELECT iid FROM ' . $rc->db->table_name(self::TABLE) + . ' WHERE (id = ? OR parent_id = ?) AND user_id = ? AND flags & ? > 0'; + $q = $rc->db->query($sql, $accountId, $accountId, $userId, self::DB_ENABLED); + $allowed = []; + while ($row = $rc->db->fetch_assoc($q)) { + $allowed[] = (int)$row['iid']; + } + $rc->output->set_env('ident_switch_allowed_identities', $allowed); + } + } else { + // Primary account: exclude identities belonging to enabled accounts/aliases + $sql = 'SELECT iid FROM ' . $rc->db->table_name(self::TABLE) + . ' WHERE user_id = ? AND flags & ? > 0'; + $q = $rc->db->query($sql, $userId, self::DB_ENABLED); + $excluded = []; + while ($row = $rc->db->fetch_assoc($q)) { + $excluded[] = (int)$row['iid']; + } + + if (!empty($excluded)) { + $sql = 'SELECT identity_id FROM ' . $rc->db->table_name('identities') + . ' WHERE user_id = ? AND del = 0'; + $q = $rc->db->query($sql, $userId); + $allowed = []; + while ($row = $rc->db->fetch_assoc($q)) { + $iid = (int)$row['identity_id']; + if (!in_array($iid, $excluded)) { + $allowed[] = $iid; + } + } + $rc->output->set_env('ident_switch_allowed_identities', $allowed); + } + } + } + + /** + * Handle preferences_list hook: customize special folders form for remote accounts. + * + * @param array $args Hook arguments containing 'section' and 'blocks' with form data. + * @return array Modified hook arguments with updated folder selections. + */ + public function on_special_folders_form(array $args): array + { + return $this->switcher->get_special_folders_form($args); + } + + /** + * Handle preferences_save hook: persist special folder assignments for remote accounts. + * + * @param array $args Hook arguments containing 'section' and 'prefs' with folder data. + * @return array Modified hook arguments, with 'abort' set to prevent default save. + */ + public function on_special_folders_update(array $args): array + { + return $this->switcher->save_special_folders($args); + } + + /** + * Handle the account switch action (AJAX). + */ + public function on_switch(): void + { + $this->switcher->switch_account(); + } + + /** + * Trim a string, returning null if the result is empty. + * + * @param string|null $str Input string. + * @return string|null Trimmed string or null if empty. + */ + public static function ntrim(?string $str): ?string + { + if ($str === null) { + return null; + } + + $s = trim($str); + return $s !== '' ? $s : null; + } + + /** + * Parse scheme prefix (ssl://, tls://) from a host string. + * + * @param string $host Host string, optionally prefixed with ssl:// or tls://. + * @return array{scheme: string, host: string} Parsed scheme and bare host. + */ + public static function parse_host_scheme(string $host): array + { + $lower = strtolower($host); + if (str_starts_with($lower, 'ssl://')) { + return ['scheme' => 'ssl', 'host' => substr($host, 6)]; + } + if (str_starts_with($lower, 'tls://')) { + return ['scheme' => 'tls', 'host' => substr($host, 6)]; + } + return ['scheme' => '', 'host' => $host]; + } + + /** + * Resolve username for an identity: use stored username or fall back to email. + * + * @param integer $iid Identity ID. + * @param string|null $username Stored username (may be empty). + * @return string Resolved username. + */ + public static function resolve_username(int $iid, ?string $username): string + { + if (!empty($username)) { + return $username; + } + + $rc = rcmail::get_instance(); + $sql = 'SELECT email FROM ' . $rc->db->table_name('identities') . ' WHERE identity_id = ?'; + $q = $rc->db->query($sql, $iid); + $r = $rc->db->fetch_assoc($q); + return $r['email'] ?? ''; + } + + /** + * Write a message to the plugin's log file. + * + * @param string $txt Log message. + */ + public static function write_log(string $txt): void + { + rcmail::get_instance()->write_log('ident_switch', $txt); + } + + /** + * Write a debug message (only when ident_switch.debug is enabled). + * + * @param string $txt Log message. + */ + public static function debug_log(string $txt): void + { + if (rcmail::get_instance()->config->get('ident_switch.debug', false)) { + rcmail::get_instance()->write_log('ident_switch', '[DEBUG] ' . $txt); + } + } } diff --git a/lib/IdentSwitchChecker.php b/lib/IdentSwitchChecker.php index 0358294..fd3cbba 100644 --- a/lib/IdentSwitchChecker.php +++ b/lib/IdentSwitchChecker.php @@ -1,4 +1,5 @@ get_checkable_identities($rc); + $identities = $this->get_checkable_identities($rc); - // Exclude the currently active secondary identity (RC already checks it) - $activeIid = (int)($_SESSION['iid' . ident_switch::MY_POSTFIX] ?? -1); - $identities = array_values(array_filter($identities, function ($id) use ($activeIid) { - return (int)$id['iid'] !== $activeIid; - })); + // Exclude the currently active secondary identity (RC already checks it) + $activeIid = (int)($_SESSION['iid' . ident_switch::MY_POSTFIX] ?? -1); + $identities = array_values(array_filter($identities, function ($id) use ($activeIid) { + return (int)$id['iid'] !== $activeIid; + })); - // When impersonating, also check the primary account - $isImpersonating = strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0; - if ($isImpersonating) { - $primary = $this->get_primary_identity($rc); - if ($primary) { - $identities[] = $primary; - } - } + // When impersonating, also check the primary account + $isImpersonating = strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0; + if ($isImpersonating) { + $primary = $this->get_primary_identity($rc); + if ($primary) { + $identities[] = $primary; + } + } - if (empty($identities)) { - $this->send_counts($rc); - return $args; - } + if (empty($identities)) { + $this->send_counts($rc); + return $args; + } - if ($rc->config->get('ident_switch.round_robin', false)) { - // Round-robin: check one identity per refresh cycle - $index = ($_SESSION['ident_switch_check_index'] ?? -1) + 1; - if ($index >= count($identities)) { - $index = 0; - } - $_SESSION['ident_switch_check_index'] = $index; - $this->check_identity($rc, $identities[$index]); - } else { - foreach ($identities as $identity) { - $this->check_identity($rc, $identity); - } - } + if ($rc->config->get('ident_switch.round_robin', false)) { + // Round-robin: check one identity per refresh cycle + $index = ($_SESSION['ident_switch_check_index'] ?? -1) + 1; + if ($index >= count($identities)) { + $index = 0; + } + $_SESSION['ident_switch_check_index'] = $index; + $this->check_identity($rc, $identities[$index]); + } else { + foreach ($identities as $identity) { + $this->check_identity($rc, $identity); + } + } - $this->send_counts($rc); + $this->send_counts($rc); - return $args; - } + return $args; + } - /** - * Check a single identity for unseen messages and notify if new mail. - * - * @param rcmail $rc Roundcube instance. - * @param array $identity Identity record from the database. - */ - private function check_identity(rcmail $rc, array $identity): void - { - $counts = $_SESSION['ident_switch_counts'] ?? []; - $previousCount = $counts[$identity['iid']]['unseen'] ?? 0; + /** + * Check a single identity for unseen messages and notify if new mail. + * + * @param rcmail $rc Roundcube instance. + * @param array $identity Identity record from the database. + */ + private function check_identity(rcmail $rc, array $identity): void + { + $counts = $_SESSION['ident_switch_counts'] ?? []; + $previousCount = $counts[$identity['iid']]['unseen'] ?? 0; - $count = $this->check_unseen($rc, $identity, $previousCount); + $count = $this->check_unseen($rc, $identity, $previousCount); - ident_switch::write_log("Check identity {$identity['iid']} ({$identity['email']}): unseen={$count}, previous={$previousCount}"); + ident_switch::write_log("Check identity {$identity['iid']} ({$identity['email']}): unseen={$count}, previous={$previousCount}"); - // Set baseline on first check; preserve it across subsequent checks - $baseline = $counts[$identity['iid']]['baseline'] ?? $count; + // Set baseline on first check; preserve it across subsequent checks + $baseline = $counts[$identity['iid']]['baseline'] ?? $count; - $counts[$identity['iid']] = [ - 'unseen' => $count, - 'baseline' => $baseline, - 'checked_at' => time(), - ]; - $_SESSION['ident_switch_counts'] = $counts; + $counts[$identity['iid']] = [ + 'unseen' => $count, + 'baseline' => $baseline, + 'checked_at' => time(), + ]; + $_SESSION['ident_switch_counts'] = $counts; - if ($count > $previousCount) { - $this->send_notification($rc, $identity, $count); - } - } + if ($count > $previousCount) { + $this->send_notification($rc, $identity, $count); + } + } - /** - * Connect to an identity's IMAP server and return INBOX unseen count. - * - * @param rcmail $rc Roundcube instance. - * @param array $identity Identity DB record. - * @param int $previousCount Previous unseen count (returned on error). - * @return int Unseen message count. - */ - private function check_unseen(rcmail $rc, array $identity, int $previousCount): int - { - $imap = new rcube_imap_generic(); + /** + * Connect to an identity's IMAP server and return INBOX unseen count. + * + * @param rcmail $rc Roundcube instance. + * @param array $identity Identity DB record. + * @param integer $previousCount Previous unseen count (returned on error). + * @return integer Unseen message count. + */ + private function check_unseen(rcmail $rc, array $identity, int $previousCount): int + { + $imap = new rcube_imap_generic(); - $parsed = ident_switch::parse_host_scheme($identity['imap_host'] ?: 'localhost'); - $host = $parsed['host']; - $ssl = $parsed['scheme'] ?: null; + $parsed = ident_switch::parse_host_scheme($identity['imap_host'] ?: 'localhost'); + $host = $parsed['host']; + $ssl = $parsed['scheme'] ?: null; - if (!$ssl && !empty($identity['flags']) && ($identity['flags'] & ident_switch::DB_SECURE_IMAP_TLS)) { - $ssl = 'tls'; // Backward compat: old records without scheme in host - } + if (!$ssl && !empty($identity['flags']) && ($identity['flags'] & ident_switch::DB_SECURE_IMAP_TLS)) { + $ssl = 'tls'; // Backward compat: old records without scheme in host + } - $def_port = ($ssl === 'ssl') ? 993 : 143; - $port = $identity['imap_port'] ?: $def_port; + $def_port = ($ssl === 'ssl') ? 993 : 143; + $port = $identity['imap_port'] ?: $def_port; - $username = $identity['username'] ?: $identity['email']; - $password = $rc->decrypt($identity['password']); - if ($password === false) { - ident_switch::write_log("Failed to decrypt password for identity {$identity['iid']}"); - return $previousCount; - } + $username = $identity['username'] ?: $identity['email']; + $password = $rc->decrypt($identity['password']); + if ($password === false) { + ident_switch::write_log("Failed to decrypt password for identity {$identity['iid']}"); + return $previousCount; + } - $result = $imap->connect($host, $username, $password, [ - 'port' => $port, - 'ssl_mode' => $ssl, - 'timeout' => 5, - ]); + $result = $imap->connect($host, $username, $password, [ + 'port' => $port, + 'ssl_mode' => $ssl, + 'timeout' => 5, + ]); - if (!$result) { - ident_switch::write_log("Failed to check mail for identity {$identity['iid']}: " . $imap->error); - return $previousCount; - } + if (!$result) { + ident_switch::write_log("Failed to check mail for identity {$identity['iid']}: " . $imap->error); + return $previousCount; + } - $status = $imap->status('INBOX', ['UNSEEN']); - $unseen = $status['UNSEEN'] ?? 0; + $status = $imap->status('INBOX', ['UNSEEN']); + $unseen = $status['UNSEEN'] ?? 0; - $imap->closeConnection(); + $imap->closeConnection(); - return $unseen; - } + return $unseen; + } - /** - * Build a virtual identity record for the primary account. - * - * When the user has switched to a secondary account, the primary account's - * connection details are saved in session with the MY_POSTFIX suffix. - * - * @param rcmail $rc Roundcube instance. - * @return array|null Identity-like array, or null if session data is missing. - */ - private function get_primary_identity(rcmail $rc): ?array - { - $postfix = ident_switch::MY_POSTFIX; + /** + * Build a virtual identity record for the primary account. + * + * When the user has switched to a secondary account, the primary account's + * connection details are saved in session with the MY_POSTFIX suffix. + * + * @param rcmail $rc Roundcube instance. + * @return array|null Identity-like array, or null if session data is missing. + */ + private function get_primary_identity(rcmail $rc): ?array + { + $postfix = ident_switch::MY_POSTFIX; - if (!isset($_SESSION['password' . $postfix])) { - return null; - } + if (!isset($_SESSION['password' . $postfix])) { + return null; + } - $host = $_SESSION['storage_host' . $postfix] ?? 'localhost'; - $port = $_SESSION['storage_port' . $postfix] ?? 143; - $ssl = $_SESSION['storage_ssl' . $postfix] ?? null; + $host = $_SESSION['storage_host' . $postfix] ?? 'localhost'; + $port = $_SESSION['storage_port' . $postfix] ?? 143; + $ssl = $_SESSION['storage_ssl' . $postfix] ?? null; - // Prepend protocol prefix so check_unseen() can parse it - if ($ssl === 'ssl' && !str_starts_with(strtolower($host), 'ssl://')) { - $host = 'ssl://' . $host; - } elseif ($ssl === 'tls' && !str_starts_with(strtolower($host), 'tls://')) { - $host = 'tls://' . $host; - } + // Prepend protocol prefix so check_unseen() can parse it + if ($ssl === 'ssl' && !str_starts_with(strtolower($host), 'ssl://')) { + $host = 'ssl://' . $host; + } elseif ($ssl === 'tls' && !str_starts_with(strtolower($host), 'tls://')) { + $host = 'tls://' . $host; + } - return [ - 'iid' => 0, - 'imap_host' => $host, - 'imap_port' => $port, - 'flags' => 0, - 'username' => $rc->user->data['username'], - 'password' => $_SESSION['password' . $postfix], - 'email' => $rc->user->data['username'], - 'label' => $_SESSION['global_alias'] ?? $rc->user->data['username'], - 'notify_basic' => null, - 'notify_sound' => null, - 'notify_desktop' => null, - ]; - } + return [ + 'iid' => 0, + 'imap_host' => $host, + 'imap_port' => $port, + 'flags' => 0, + 'username' => $rc->user->data['username'], + 'password' => $_SESSION['password' . $postfix], + 'email' => $rc->user->data['username'], + 'label' => $_SESSION['global_alias'] ?? $rc->user->data['username'], + 'notify_basic' => null, + 'notify_sound' => null, + 'notify_desktop' => null, + ]; + } - /** - * Get all enabled identities that have mail checking enabled. - * - * @param rcmail $rc Roundcube instance. - * @return array List of identity records. - */ - private function get_checkable_identities(rcmail $rc): array - { - $sql = 'SELECT isw.iid, isw.imap_host, isw.imap_port, isw.flags, ' - . 'isw.username, isw.password, isw.label, ' - . 'isw.notify_basic, isw.notify_sound, isw.notify_desktop, ' - . 'ii.email ' - . 'FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' isw ' - . 'INNER JOIN ' . $rc->db->table_name('identities') . ' ii ON isw.iid = ii.identity_id ' - . 'WHERE isw.user_id = ? AND isw.flags & ? > 0 AND isw.notify_check = ? AND isw.parent_id IS NULL'; + /** + * Get all enabled identities that have mail checking enabled. + * + * @param rcmail $rc Roundcube instance. + * @return array List of identity records. + */ + private function get_checkable_identities(rcmail $rc): array + { + $sql = 'SELECT isw.iid, isw.imap_host, isw.imap_port, isw.flags, ' + . 'isw.username, isw.password, isw.label, ' + . 'isw.notify_basic, isw.notify_sound, isw.notify_desktop, ' + . 'ii.email ' + . 'FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' isw ' + . 'INNER JOIN ' . $rc->db->table_name('identities') . ' ii ON isw.iid = ii.identity_id ' + . 'WHERE isw.user_id = ? AND isw.flags & ? > 0 AND isw.notify_check = ? AND isw.parent_id IS NULL'; - $q = $rc->db->query($sql, $rc->user->ID, ident_switch::DB_ENABLED, ident_switch::NOTIFY_CHECK_ENABLED); + $q = $rc->db->query($sql, $rc->user->ID, ident_switch::DB_ENABLED, ident_switch::NOTIFY_CHECK_ENABLED); - $identities = []; - while ($r = $rc->db->fetch_assoc($q)) { - $identities[] = $r; - } + $identities = []; + while ($r = $rc->db->fetch_assoc($q)) { + $identities[] = $r; + } - return $identities; - } + return $identities; + } - /** - * Send all cached unseen counts to client JS. - * - * @param rcmail $rc Roundcube instance. - */ - private function send_counts(rcmail $rc): void - { - $counts = $_SESSION['ident_switch_counts'] ?? []; - $data = []; - foreach ($counts as $iid => $info) { - $data[$iid] = [ - 'unseen' => $info['unseen'], - 'baseline' => $info['baseline'] ?? $info['unseen'], - ]; - } + /** + * Send all cached unseen counts to client JS. + * + * @param rcmail $rc Roundcube instance. + */ + private function send_counts(rcmail $rc): void + { + $counts = $_SESSION['ident_switch_counts'] ?? []; + $data = []; + foreach ($counts as $iid => $info) { + $data[$iid] = [ + 'unseen' => $info['unseen'], + 'baseline' => $info['baseline'] ?? $info['unseen'], + ]; + } - $rc->output->command('plugin.ident_switch.update_counts', $data); - } + $rc->output->command('plugin.ident_switch.update_counts', $data); + } - /** - * Send notification command to client for a specific identity. - * - * @param rcmail $rc Roundcube instance. - * @param array $identity Identity record. - * @param int $count New unseen count. - */ - private function send_notification(rcmail $rc, array $identity, int $count): void - { - $basic = $identity['notify_basic'] ?? $rc->config->get('newmail_notifier_basic', false); - $sound = $identity['notify_sound'] ?? $rc->config->get('newmail_notifier_sound', false); - $desktop = $identity['notify_desktop'] ?? $rc->config->get('newmail_notifier_desktop', false); + /** + * Send notification command to client for a specific identity. + * + * @param rcmail $rc Roundcube instance. + * @param array $identity Identity record. + * @param integer $count New unseen count. + */ + private function send_notification(rcmail $rc, array $identity, int $count): void + { + $basic = $identity['notify_basic'] ?? $rc->config->get('newmail_notifier_basic', false); + $sound = $identity['notify_sound'] ?? $rc->config->get('newmail_notifier_sound', false); + $desktop = $identity['notify_desktop'] ?? $rc->config->get('newmail_notifier_desktop', false); - $label = $identity['label'] ?: $identity['email']; + $label = $identity['label'] ?: $identity['email']; - $rc->output->command('plugin.ident_switch.notify', [ - 'iid' => $identity['iid'], - 'label' => $label, - 'count' => $count, - 'basic' => (bool)$basic, - 'sound' => (bool)$sound, - 'desktop' => (bool)$desktop, - ]); - } + $rc->output->command('plugin.ident_switch.notify', [ + 'iid' => $identity['iid'], + 'label' => $label, + 'count' => $count, + 'basic' => (bool)$basic, + 'sound' => (bool)$sound, + 'desktop' => (bool)$desktop, + ]); + } } diff --git a/lib/IdentSwitchForm.php b/lib/IdentSwitchForm.php index 80a8060..427d25d 100644 --- a/lib/IdentSwitchForm.php +++ b/lib/IdentSwitchForm.php @@ -1,4 +1,5 @@ plugin = $plugin; - } - - /** - * Build the common form fields for identity settings. - * - * @param array $record Identity record data used for placeholders. - * @return array Form field definitions for mode select, label, and readonly. - */ - public function get_common_fields(array &$record, bool $domainAllowed = true, string $warningHtml = ''): array - { - $prefix = 'ident_switch.form.common.'; - $rc = rcmail::get_instance(); - - // Build mode select: Primary / Alias of X / Separate account - $modeSelect = new html_select([ - 'name' => "_{$prefix}mode", - 'onchange' => 'plugin_switchIdent_mode_onChange(this.value);', - ]); - $modeSelect->add($this->plugin->gettext('form.common.mode.primary'), 'primary'); - - // Add available accounts as alias targets - $accounts = $this->get_available_accounts($rc, $record['identity_id'] ?? null); - foreach ($accounts as $acc) { - $label = $acc['label'] ?: $acc['email']; - $modeSelect->add($label, 'alias:' . $acc['id']); - } - - // Only offer "Separate account" when domain is allowed - if ($domainAllowed) { - $modeSelect->add($this->plugin->gettext('form.common.mode.separate'), 'separate'); - } - - $currentMode = $record["{$prefix}mode"] ?? 'primary'; - $modeHtml = $modeSelect->show($currentMode) - . html::span( - ['class' => 'form-text'], - rcube::Q($this->plugin->gettext('form.common.mode.hint')) - ) - . $warningHtml; - - return [ - $prefix . 'mode' => ['value' => $modeHtml], - $prefix . 'readonly' => ['type' => 'hidden'], - ]; - } - - /** - * Get all separate accounts available as alias targets. - * - * @param rcmail $rc Roundcube instance. - * @param int|null $excludeIid Identity ID to exclude (the identity being edited). - * @return array List of account records with id, label, username, email. - */ - private function get_available_accounts(rcmail $rc, ?int $excludeIid): array - { - $sql = 'SELECT isw.id, isw.label, isw.username, ii.email' - . ' FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' isw' - . ' INNER JOIN ' . $rc->db->table_name('identities') . ' ii ON isw.iid = ii.identity_id' - . ' WHERE isw.user_id = ? AND isw.flags & ? > 0 AND isw.parent_id IS NULL'; - - $params = [$rc->user->ID, ident_switch::DB_ENABLED]; - - if ($excludeIid !== null) { - $sql .= ' AND isw.iid != ?'; - $params[] = $excludeIid; - } - - $q = $rc->db->query($sql, ...$params); - $accounts = []; - while ($r = $rc->db->fetch_assoc($q)) { - $accounts[] = $r; - } - return $accounts; - } - - /** - * Build the separate account header fields (label). - * - * @param array $record Identity record data. - * @return array Form field definitions for label. - */ - public function get_separate_fields(array &$record): array - { - $prefix = 'ident_switch.form.common.'; - - $labelInput = new html_inputfield([ - 'name' => "_{$prefix}label", - 'type' => 'text', - 'size' => 32, - 'placeholder' => $record['email'] ?? '', - ]); - $labelHtml = $labelInput->show($record["{$prefix}label"] ?? '') - . html::span( - ['class' => 'form-text'], - rcube::Q($this->plugin->gettext('form.common.label.hint')) - ); - - return [ - $prefix . 'label' => ['value' => $labelHtml], - ]; - } - - /** - * Build the IMAP form fields for identity settings. - * - * @param array $record Identity record data used for placeholders. - * @return array Form field definitions for IMAP host, port, security, username, password, delimiter. - */ - public function get_imap_fields(array &$record): array - { - $prefix = 'ident_switch.form.imap.'; - return [ - $prefix . 'host' => ['type' => 'text', 'size' => 64, 'placeholder' => 'localhost'], - $prefix . 'security' => ['value' => $this->build_security_select($prefix, $record, 'ssl')], - $prefix . 'port' => ['type' => 'text', 'size' => 5, 'placeholder' => 993], - $prefix . 'username' => ['type' => 'text', 'size' => 64, 'placeholder' => $record['email'] ?? ''], - $prefix . 'password' => ['type' => 'password', 'size' => 64, 'autocomplete' => 'off'], - $prefix . 'delimiter' => ['value' => $this->build_delimiter_field($prefix, $record)], - ]; - } - - /** - * Build the SMTP form fields for identity settings. - * - * @param array $record Identity record data used for default auth type selection. - * @return array Form field definitions for SMTP host, port, and auth type. - */ - public function get_smtp_fields(array &$record): array - { - $prefix = 'ident_switch.form.smtp.'; - - $authType = new html_select(['name' => "_{$prefix}auth"]); - $authType->add($this->plugin->gettext('form.smtp.auth.imap'), ident_switch::SMTP_AUTH_IMAP); - $authType->add($this->plugin->gettext('form.smtp.auth.none'), ident_switch::SMTP_AUTH_NONE); - $authType->add($this->plugin->gettext('form.smtp.auth.custom'), ident_switch::SMTP_AUTH_CUSTOM); - - // Cast to int: html_select::show() uses strict comparison (===), - // option values are integers (constants), but POST/DB may return strings. - $authVal = isset($record[$prefix . 'auth']) ? (int)$record[$prefix . 'auth'] : null; - - return [ - $prefix . 'host' => ['type' => 'text', 'size' => 64, 'placeholder' => 'localhost'], - $prefix . 'security' => ['value' => $this->build_security_select($prefix, $record, 'tls')], - $prefix . 'port' => ['type' => 'text', 'size' => 5, 'placeholder' => 587], - $prefix . 'auth' => ['value' => $authType->show($authVal !== null ? [$authVal] : [])], - $prefix . 'username' => ['type' => 'text', 'size' => 64, 'autocomplete' => 'off'], - $prefix . 'password' => ['type' => 'password', 'size' => 64, 'autocomplete' => 'off'], - ]; - } - - /** - * Build the Sieve (managesieve) form fields for identity settings. - * - * @param array $record Identity record data used for default auth type selection. - * @return array Form field definitions for Sieve host, port, and auth type. - */ - public function get_sieve_fields(array &$record): array - { - $prefix = 'ident_switch.form.sieve.'; - - $authType = new html_select(['name' => "_{$prefix}auth"]); - $authType->add($this->plugin->gettext('form.sieve.auth.imap'), ident_switch::SIEVE_AUTH_IMAP); - $authType->add($this->plugin->gettext('form.sieve.auth.none'), ident_switch::SIEVE_AUTH_NONE); - $authType->add($this->plugin->gettext('form.sieve.auth.custom'), ident_switch::SIEVE_AUTH_CUSTOM); - - // Cast to int: html_select::show() uses strict comparison (===) - $authVal = isset($record[$prefix . 'auth']) ? (int)$record[$prefix . 'auth'] : null; - - return [ - $prefix . 'host' => ['type' => 'text', 'size' => 64, 'placeholder' => 'localhost'], - $prefix . 'security' => ['value' => $this->build_security_select($prefix, $record, 'tls')], - $prefix . 'port' => ['type' => 'text', 'size' => 5, 'placeholder' => 4190], - $prefix . 'auth' => ['value' => $authType->show($authVal !== null ? [$authVal] : [])], - $prefix . 'username' => ['type' => 'text', 'size' => 64, 'autocomplete' => 'off'], - $prefix . 'password' => ['type' => 'password', 'size' => 64, 'autocomplete' => 'off'], - ]; - } - - /** - * Build the notification form fields for identity settings. - * - * Shows the actual newmail_notifier default value in each tri-state select. - * Selecting the default option stores NULL so changes to global defaults propagate. - * - * @param array $record Identity record data used for default values. - * @return array Form field definitions for notification preferences. - */ - public function get_notification_fields(array &$record): array - { - $rc = rcmail::get_instance(); - $prefix = 'ident_switch.form.notify.'; - - $defaultKeys = [ - 'basic' => 'newmail_notifier_basic', - 'sound' => 'newmail_notifier_sound', - 'desktop' => 'newmail_notifier_desktop', - ]; - - $triState = function (string $name) use ($prefix, &$record, $rc, $defaultKeys): string { - $defaultVal = $rc->config->get($defaultKeys[$name] ?? '', false); - $defaultLabel = $defaultVal - ? $this->plugin->gettext('form.notify.on') - : $this->plugin->gettext('form.notify.off'); - $defaultLabel .= ' (' . strtolower($this->plugin->gettext('form.notify.default')) . ')'; - - $select = new html_select(['name' => "_{$prefix}{$name}"]); - $select->add($defaultLabel, ''); - $select->add($this->plugin->gettext('form.notify.on'), '1'); - $select->add($this->plugin->gettext('form.notify.off'), '0'); - return $select->show($record[$prefix . $name] ?? ''); - }; - - return [ - $prefix . 'check' => ['type' => 'checkbox'], - $prefix . 'basic' => ['value' => $triState('basic')], - $prefix . 'sound' => ['value' => $triState('sound')], - $prefix . 'desktop' => ['value' => $triState('desktop')], - ]; - } - - /** - * Build a security dropdown select for a protocol section. - * - * @param string $prefix Form field prefix (e.g. 'ident_switch.form.imap.'). - * @param array $record Identity record data. - * @param string $default Default security value ('', 'tls', 'ssl'). - * @return string Rendered HTML select element. - */ - private function build_security_select(string $prefix, array &$record, string $default): string - { - $select = new html_select(['name' => "_{$prefix}security"]); - $select->add($this->plugin->gettext('form.security.none'), ''); - $select->add($this->plugin->gettext('form.security.starttls'), 'tls'); - $select->add($this->plugin->gettext('form.security.ssl'), 'ssl'); - - $current = $record[$prefix . 'security'] ?? $default; - $proto = str_replace('ident_switch.form.', '', rtrim($prefix, '.')); - $hidden = $current !== '' ? ' style="display:none"' : ''; - $warning = '
' - . rcube::Q($this->plugin->gettext('form.security.none_warning')) - . '
'; - - return $select->show($current) . $warning; - } - - /** - * Build the delimiter field with Auto/Manual mode select. - * - * @param string $prefix Form field prefix (e.g. 'ident_switch.form.imap.'). - * @param array $record Identity record data. - * @return string Rendered HTML select + text input. - */ - private function build_delimiter_field(string $prefix, array &$record): string - { - $delimValue = $record[$prefix . 'delimiter'] ?? ''; - $mode = ($delimValue !== '' && $delimValue !== null) ? 'manual' : 'auto'; - - $select = new html_select(['name' => "_{$prefix}delimiter_mode"]); - $select->add($this->plugin->gettext('form.imap.delimiter.auto'), 'auto'); - $select->add($this->plugin->gettext('form.imap.delimiter.manual'), 'manual'); - - $hidden = $mode === 'auto' ? ' style="display:none"' : ''; - $input = new html_inputfield(['name' => "_{$prefix}delimiter", 'size' => 1]); - - return $select->show($mode) - . ' ' - . $input->show($delimValue) - . ''; - } - - /** - * Check if a domain is allowed for ident_switch configuration. - * - * When 'ident_switch.preconfig_only' is enabled, only domains with - * a matching preconfig entry are allowed. - * - * @param string $email Email address to check. - * @return bool True if allowed, false if blocked by preconfig_only. - */ - private function is_domain_allowed(string $email): bool - { - $rc = rcmail::get_instance(); - if (!$rc->config->get('ident_switch.preconfig_only', false)) { - return true; - } - $preconfig = new IdentSwitchPreconfig($this->plugin); - return $preconfig->get($email) !== false; - } - - /** - * Pass preconfig data and settings to JS environment. - * - * Parses each domain's protocol URLs into host/security/port components - * so the client can dynamically populate form fields on email change. - * - * @param rcmail $rc Roundcube instance. - */ - private function pass_preconfig_to_js(rcmail $rc): void - { - $this->plugin->load_config(); - $allPreconfig = $rc->config->get('ident_switch.preconfig', []); - $preconfigOnly = $rc->config->get('ident_switch.preconfig_only', false); - - $jsPreconfig = []; - foreach ($allPreconfig as $domain => $cfg) { - $entry = []; - - $protocols = [ - 'imap' => $cfg['imap_host'] ?? $cfg['host'] ?? '', - 'smtp' => $cfg['smtp_host'] ?? $cfg['host'] ?? '', - 'sieve' => $cfg['sieve_host'] ?? '', - ]; - - foreach ($protocols as $proto => $url) { - if (empty($url)) { - continue; - } - $urlArr = parse_url($url); - if (!is_array($urlArr)) { - continue; - } - $scheme = strtolower($urlArr['scheme'] ?? ''); - $entry[$proto] = [ - 'host' => $urlArr['host'] ?? '', - 'security' => in_array($scheme, ['ssl', 'tls']) ? $scheme : '', - 'port' => !empty($urlArr['port']) ? intval($urlArr['port']) : '', - ]; - } - - $entry['user'] = $cfg['user'] ?? ''; - $entry['readonly'] = !empty($cfg['readonly']); - $jsPreconfig[$domain] = $entry; - } - - $rc->output->set_env('ident_switch_preconfig', $jsPreconfig); - $rc->output->set_env('ident_switch_preconfig_only', $preconfigOnly); - $rc->output->set_env('ident_switch_warning_tpl', $this->plugin->gettext('form.preconfig_only_warning')); - } - - /** - * Delegate to ident_switch::parse_host_scheme(). - */ - private static function parse_host_scheme(string $host): array - { - return ident_switch::parse_host_scheme($host); - } - - /** - * Compose a host string with scheme prefix. - * - * @param string|null $host Bare host name. - * @param string|null $security Security type ('', 'tls', 'ssl'). - * @return string|null Host with scheme prefix, or null if host is empty. - */ - private static function compose_host_scheme(?string $host, ?string $security): ?string - { - if ($host === null || $host === '') { - return $host; - } - if ($security === 'ssl' || $security === 'tls') { - return $security . '://' . $host; - } - return $host; - } - - /** - * Restore plugin form field values from POST data after a save error. - * - * When the identity save is aborted (validation or connection error), - * Roundcube re-renders the form. Core fields are preserved from POST by RC, - * but plugin fields would revert to DB values without this restoration. - * - * @param array &$record Identity record to overlay POST values onto. - */ - private function restore_post_values(array &$record): void - { - // Text and select fields (trimmed) - $fields = [ - ['common', 'label'], - ['imap', 'host'], - ['imap', 'port'], - ['imap', 'username'], - ['imap', 'delimiter'], - ['smtp', 'host'], - ['smtp', 'port'], - ['smtp', 'auth'], - ['smtp', 'username'], - ['sieve', 'host'], - ['sieve', 'port'], - ['sieve', 'auth'], - ['sieve', 'username'], - ['notify', 'basic'], - ['notify', 'sound'], - ['notify', 'desktop'], - ]; - - foreach ($fields as [$section, $field]) { - $rawVal = self::get_field_value($section, $field, false); - if ($rawVal !== null) { - $record["ident_switch.form.{$section}.{$field}"] = self::get_field_value($section, $field); - } - } - - // Security selects: empty string means "None", must not be trimmed to null - foreach (['imap', 'smtp', 'sieve'] as $proto) { - $rawVal = self::get_field_value($proto, 'security', false); - if ($rawVal !== null) { - $record["ident_switch.form.{$proto}.security"] = $rawVal; - } - } - - // Password fields (raw, no trim) - foreach (['imap', 'smtp', 'sieve'] as $proto) { - $rawVal = self::get_field_value($proto, 'password', false, true); - if ($rawVal !== null) { - $record["ident_switch.form.{$proto}.password"] = $rawVal; - } - } - - // Mode select - $modeVal = self::get_field_value('common', 'mode', false); - if ($modeVal !== null) { - $record['ident_switch.form.common.mode'] = $modeVal; - } - - // Checkbox: absent from POST means unchecked - $record['ident_switch.form.notify.check'] = !empty(self::get_field_value('notify', 'check', false)); - } - - /** - * Test IMAP, SMTP, and Sieve connections using validated form data. - * - * @param array $data Validated data from validate(). - * @param string $email Email address (fallback username). - * @param string $imapPass Raw IMAP password (not encrypted). - * @return string|null Error key on failure, null on success. - */ - private function test_connections(array $data, string $email, string $imapPass): ?string - { - $rc = rcmail::get_instance(); - - // --- IMAP test --- - $imapHostFull = $data['imap.host'] ?: 'localhost'; - $parsed = self::parse_host_scheme($imapHostFull); - $imapHost = $parsed['host']; - $imapSsl = $parsed['scheme'] ?: null; - $imapDefPort = ($imapSsl === 'ssl') ? 993 : 143; - $imapPort = $data['imap.port'] ?: $imapDefPort; - $imapUser = $data['imap.user'] ?: $email; - - $imap = new rcube_imap_generic(); - $result = $imap->connect($imapHost, $imapUser, $imapPass, [ - 'port' => (int)$imapPort, - 'ssl_mode' => $imapSsl, - 'timeout' => 10, - ]); - if (!$result) { - ident_switch::write_log("IMAP connection test failed: {$imap->error}"); - return 'imap.connect'; - } - $imap->closeConnection(); - - // --- SMTP test --- - $smtpAuth = (int)($data['smtp.auth'] ?? ident_switch::SMTP_AUTH_IMAP); - if ($smtpAuth !== ident_switch::SMTP_AUTH_NONE) { - $smtpHostFull = $data['smtp.host'] ?: 'localhost'; - $smtpParsed = self::parse_host_scheme($smtpHostFull); - $smtpDefPort = ($smtpParsed['scheme'] === 'ssl') ? 465 : 587; - $smtpPort = $data['smtp.port'] ?: $smtpDefPort; - // Compose host with scheme for rcube_smtp (expects ssl://host:port or tls://host:port) - $smtpConnHost = self::compose_host_scheme($smtpParsed['host'], $smtpParsed['scheme'] ?: null); - $smtpConnHost .= ':' . $smtpPort; - - if ($smtpAuth === ident_switch::SMTP_AUTH_CUSTOM) { - $smtpUser = $data['smtp.user'] ?: ''; - $smtpPass = $data['smtp.pass'] ?: ''; - } else { - $smtpUser = $imapUser; - $smtpPass = $imapPass; - } - - $smtp = new rcube_smtp(); - $result = $smtp->connect($smtpConnHost, null, $smtpUser, $smtpPass); - if (!$result) { - ident_switch::write_log("SMTP connection test failed"); - return 'smtp.connect'; - } - $smtp->disconnect(); - } - - // --- Sieve test (only if host is configured and auth is not None) --- - $sieveAuth = (int)($data['sieve.auth'] ?? ident_switch::SIEVE_AUTH_IMAP); - $sieveHostFull = $data['sieve.host'] ?? ''; - if (!empty($sieveHostFull) && $sieveAuth !== ident_switch::SIEVE_AUTH_NONE) { - $sieveParsed = self::parse_host_scheme($sieveHostFull); - $sievePort = $data['sieve.port'] ?: 4190; - $useTls = ($sieveParsed['scheme'] === 'tls'); - $sieveHost = $sieveParsed['host']; - if ($sieveParsed['scheme'] === 'ssl') { - $sieveHost = 'ssl://' . $sieveHost; - } - - if ($sieveAuth === ident_switch::SIEVE_AUTH_CUSTOM) { - $sieveUser = $data['sieve.user'] ?: ''; - $sievePass = $data['sieve.pass'] ?: ''; - } else { - $sieveUser = $imapUser; - $sievePass = $imapPass; - } - - if (class_exists('rcube_sieve')) { - $sieve = new rcube_sieve($sieveUser, $sievePass, $sieveHost, (int)$sievePort, null, $useTls); - if ($sieve->error()) { - ident_switch::write_log("Sieve connection test failed (error code: {$sieve->error()})"); - return 'sieve.connect'; - } - } - } - - return null; - } - - /** - * Handle identity_form hook: add plugin-specific fields to the identity editor. - * - * Loads existing account data from the database (if editing) or applies - * preconfigured settings, then adds Common/IMAP/SMTP/Sieve sections to the form. - * - * @param array $args Hook arguments containing 'record' with identity data. - * @param IdentSwitchPreconfig $preconfig Preconfiguration handler. - * @return array Modified hook arguments with added form sections. - */ - public function on_identity_form(array $args, IdentSwitchPreconfig $preconfig): array - { - $rc = rcmail::get_instance(); - - // When creating a new identity, record may be null - if ($args['record'] === null) { - $args['record'] = []; - } - - // Do not show options for default identity - if (!empty($args['record']['email']) && strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { - return $args; - } - - $this->plugin->add_texts('localization'); - - $preconfigOnly = $rc->config->get('ident_switch.preconfig_only', false); - $domainAllowed = empty($args['record']['email']) || $this->is_domain_allowed($args['record']['email']); - - // Build domain warning HTML (included in mode field) - $warningHtml = ''; - if ($preconfigOnly) { - $email = $args['record']['email'] ?? ''; - $domain = ''; - if (!empty($email) && str_contains($email, '@')) { - $domain = substr($email, strpos($email, '@') + 1); - } - $warningHtml = html::div( - ['class' => 'boxwarning', 'id' => 'ident-switch-domain-warning', - 'style' => $domainAllowed ? 'display:none' : ''], - rcube::Q(sprintf($this->plugin->gettext('form.preconfig_only_warning'), $domain)) - ); - } - - // Pass preconfig data to JS for dynamic form updates - $this->pass_preconfig_to_js($rc); - - $row = null; - if (isset($args['record']['identity_id'])) { - $sql = 'SELECT * FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $args['record']['identity_id'], $rc->user->ID); - $row = $rc->db->fetch_assoc($q); - } - - $record = &$args['record']; - - // Tell JS whether this identity has an existing ident_switch record - $rc->output->set_env('ident_switch_has_record', !empty($row)); - - // Load data if exists - if ($row) { - $dbToForm = [ - 'label' => 'common.label', - 'imap_host' => 'imap.host', - 'imap_port' => 'imap.port', - 'imap_delimiter' => 'imap.delimiter', - 'username' => 'imap.username', - 'password' => 'imap.password', - 'smtp_host' => 'smtp.host', - 'smtp_port' => 'smtp.port', - 'smtp_auth' => 'smtp.auth', - 'smtp_username' => 'smtp.username', - 'smtp_password' => 'smtp.password', - 'sieve_host' => 'sieve.host', - 'sieve_port' => 'sieve.port', - 'sieve_auth' => 'sieve.auth', - 'sieve_username' => 'sieve.username', - 'sieve_password' => 'sieve.password', - 'notify_check' => 'notify.check', - 'notify_basic' => 'notify.basic', - 'notify_sound' => 'notify.sound', - 'notify_desktop' => 'notify.desktop', - ]; - foreach ($row as $k => $v) { - if (isset($dbToForm[$k])) { - $record['ident_switch.form.' . $dbToForm[$k]] = $v; - } - } - - // Replace encrypted passwords with sentinel (shows dots in form, detectable on save) - foreach (['imap.password', 'smtp.password', 'sieve.password'] as $passKey) { - $fullKey = 'ident_switch.form.' . $passKey; - if (!empty($record[$fullKey])) { - $record[$fullKey] = self::PASSWORD_SENTINEL; - } - } - - // Determine mode: alias (has parent_id) or separate account - if ($row['parent_id'] !== null) { - $record['ident_switch.form.common.mode'] = 'alias:' . $row['parent_id']; - } elseif ($row['flags'] & ident_switch::DB_ENABLED) { - $record['ident_switch.form.common.mode'] = 'separate'; - } else { - $record['ident_switch.form.common.mode'] = 'primary'; - } - - // Parse host schemes into separate security fields - foreach (['imap', 'smtp', 'sieve'] as $proto) { - $key = "ident_switch.form.{$proto}.host"; - $hostVal = $record[$key] ?? ''; - if ($hostVal !== '') { - $parsed = self::parse_host_scheme($hostVal); - $record[$key] = $parsed['host']; - $record["ident_switch.form.{$proto}.security"] = $parsed['scheme']; - } - } - - // Backward compat: if IMAP host had no scheme but TLS flag was set - if (empty($record['ident_switch.form.imap.security']) && ($row['flags'] & ident_switch::DB_SECURE_IMAP_TLS)) { - $record['ident_switch.form.imap.security'] = 'tls'; - } - - // Set readonly if needed - $cfg = $preconfig->get($record['email']); - if (is_array($cfg) && ($cfg['readonly'] ?? false)) { - $record['ident_switch.form.common.readonly'] = 1; - if (in_array(strtoupper($cfg['user'] ?? ''), ['EMAIL', 'MBOX'])) { - $record['ident_switch.form.common.readonly'] = 2; - } - // Override delimiter from preconfig (auto-detect if not specified) - $record['ident_switch.form.imap.delimiter'] = $cfg['delimiter'] ?? null; - } - } else { - $preconfig->apply($record); - } - - // Restore POST values when form is re-displayed after a save error - if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $this->restore_post_values($record); - } - - $args['form']['ident_switch.common'] = [ - 'name' => $this->plugin->gettext('form.common.general'), - 'content' => $this->get_common_fields($record, $domainAllowed, $warningHtml), - ]; - $args['form']['ident_switch.separate'] = [ - 'name' => $this->plugin->gettext('form.caption'), - 'content' => $this->get_separate_fields($record), - ]; - $args['form']['ident_switch.imap'] = [ - 'name' => $this->plugin->gettext('form.imap.caption'), - 'content' => $this->get_imap_fields($record), - ]; - $args['form']['ident_switch.smtp'] = [ - 'name' => $this->plugin->gettext('form.smtp.caption'), - 'content' => $this->get_smtp_fields($record), - ]; - $args['form']['ident_switch.sieve'] = [ - 'name' => $this->plugin->gettext('form.sieve.caption'), - 'content' => $this->get_sieve_fields($record), - ]; - if (!$rc->config->get('ident_switch.check_mail', true)) { - // Admin disabled background mail checking - } elseif ($rc->plugins->get_plugin('newmail_notifier')) { - $args['form']['ident_switch.notify'] = [ - 'name' => $this->plugin->gettext('form.notify.caption'), - 'content' => $this->get_notification_fields($record), - ]; - } elseif (!$rc->config->get('ident_switch.hide_notifier_warning', false)) { - $args['form']['ident_switch.notify'] = [ - 'name' => $this->plugin->gettext('form.notify.caption'), - 'content' => html::div( - ['class' => 'boxinformation'], - rcube::Q($this->plugin->gettext('form.notify.requires_newmail_notifier')) - ), - ]; - } - - return $args; - } - - /** - * Handle identity_update hook: validate and save plugin fields on identity edit. - * - * @param array $args Hook arguments containing 'id' and 'record' with identity data. - * @return array Modified hook arguments, with 'abort' set on validation failure. - */ - public function on_identity_update(array $args): array - { - $rc = rcmail::get_instance(); - - // Do not do anything for default identity - if (strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { - return $args; - } - - $mode = self::get_field_value('common', 'mode', false) ?? 'primary'; - - // Block separate mode for non-preconfigured domains (alias and primary are always allowed) - if (!$this->is_domain_allowed($args['record']['email']) && $mode === 'separate') { - $this->disable($args['id']); - return $args; - } - - if ($mode === 'primary') { - $this->disable($args['id']); - return $args; - } - - if (str_starts_with($mode, 'alias:')) { - $parentId = (int)substr($mode, 6); - $this->save_alias($args['id'], $parentId); - return $args; - } - - // mode === 'separate': full validation + save - $data = $this->validate(); - if (!empty($data['err'])) { - $this->plugin->add_texts('localization'); - $args['abort'] = true; - $args['result'] = false; - $args['message'] = 'ident_switch.err.' . $data['err']; - return $args; - } - - $this->apply_readonly_preconfig($data, $args['record']['email']); - - // Resolve sentinel passwords to actual passwords for connection testing - $testPass = $this->resolve_password_for_test($rc, $args['id'], $data['imap.pass']); - - // Test connections before saving - $connErr = $this->test_connections($data, $args['record']['email'], $testPass); - if ($connErr) { - $this->plugin->add_texts('localization'); - $args['abort'] = true; - $args['result'] = false; - $args['message'] = 'ident_switch.err.' . $connErr; - return $args; - } - - $data['id'] = $args['id']; - $this->save($rc, $data); - - return $args; - } - - /** - * Handle identity_create hook: validate plugin fields before identity creation. - * - * Stores validated data in session since identity_id is not yet available. - * - * @param array $args Hook arguments containing 'record' with identity data. - * @return array Modified hook arguments, with 'abort' set on validation failure. - */ - public function on_identity_create(array $args): array - { - $rc = rcmail::get_instance(); - - // Do not do anything for default identity - if (strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { - return $args; - } - - $mode = self::get_field_value('common', 'mode', false) ?? 'primary'; - - // Block separate mode for non-preconfigured domains (alias and primary are always allowed) - if (!$this->is_domain_allowed($args['record']['email']) && $mode === 'separate') { - return $args; - } - - if ($mode === 'primary') { - return $args; - } - - if (str_starts_with($mode, 'alias:')) { - $parentId = (int)substr($mode, 6); - $_SESSION['createData' . ident_switch::MY_POSTFIX] = [ - 'mode' => 'alias', - 'parent_id' => $parentId, - 'label' => self::get_field_value('common', 'label'), - ]; - return $args; - } - - // mode === 'separate': full validation - $data = $this->validate(); - if (!empty($data['err'])) { - $this->plugin->add_texts('localization'); - $args['abort'] = true; - $args['result'] = false; - $args['message'] = 'ident_switch.err.' . $data['err']; - return $args; - } - - $this->apply_readonly_preconfig($data, $args['record']['email']); - - // For new identities, sentinel should not appear, but handle defensively - $testPass = ($data['imap.pass'] === self::PASSWORD_SENTINEL) ? '' : $data['imap.pass']; - - // Test connections before saving - $connErr = $this->test_connections($data, $args['record']['email'], $testPass); - if ($connErr) { - $this->plugin->add_texts('localization'); - $args['abort'] = true; - $args['result'] = false; - $args['message'] = 'ident_switch.err.' . $connErr; - return $args; - } - - // Save data for _after (cannot pass with $args) - $_SESSION['createData' . ident_switch::MY_POSTFIX] = $data; - - return $args; - } - - /** - * Handle identity_create_after hook: persist plugin data after identity creation. - * - * Retrieves validated data from session and saves it with the new identity_id. - * - * @param array $args Hook arguments containing 'id' (new identity_id) and 'record'. - * @return array Unmodified hook arguments. - */ - public function on_identity_create_after(array $args): array - { - $rc = rcmail::get_instance(); - - // Do not do anything for default identity - if (strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { - return $args; - } - - $data = $_SESSION['createData' . ident_switch::MY_POSTFIX] ?? null; - - unset($_SESSION['createData' . ident_switch::MY_POSTFIX]); - if (!$data || count($data) === 0) { - ident_switch::write_log("Object with ident_switch values not found in session for ID = {$args['id']}."); - } elseif (($data['mode'] ?? '') === 'alias') { - $this->save_alias($args['id'], $data['parent_id']); - } else { - $data['id'] = $args['id']; - $this->save($rc, $data); - } - - return $args; - } - - /** - * Handle identity_delete hook: remove plugin data when an identity is deleted. - * - * @param array $args Hook arguments containing 'id' of the identity being deleted. - * @return array Unmodified hook arguments. - */ - public function on_identity_delete(array $args): array - { - $rc = rcmail::get_instance(); - - $sql = 'DELETE FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $args['id'], $rc->user->ID); - - if ($rc->db->affected_rows($q)) { - ident_switch::write_log("Deleted associated information for identity with ID = {$args['id']}."); - } - - return $args; - } - - /** - * Fill in missing validated data from preconfig when identity is readonly. - * - * When a preconfig has readonly=true, form fields are disabled in the - * browser and not submitted via POST. This fills the missing values - * from the preconfig on the server side. - * - * @param array $data Validated data from validate(), modified in place. - * @param string $email Email address of the identity. - */ - private function apply_readonly_preconfig(array &$data, string $email): void - { - $preconfig = new IdentSwitchPreconfig($this->plugin); - $cfg = $preconfig->get($email); - if (!is_array($cfg) || empty($cfg['readonly'])) { - return; - } - - // Build temporary record to get preconfig values - $record = ['email' => $email]; - $preconfig->apply($record); - - // Map simple preconfig record keys to validated data keys - $map = [ - 'ident_switch.form.imap.port' => 'imap.port', - 'ident_switch.form.imap.delimiter' => 'imap.delimiter', - 'ident_switch.form.imap.username' => 'imap.user', - 'ident_switch.form.smtp.port' => 'smtp.port', - 'ident_switch.form.sieve.port' => 'sieve.port', - ]; - - foreach ($map as $recordKey => $dataKey) { - if (empty($data[$dataKey]) && isset($record[$recordKey])) { - $data[$dataKey] = $record[$recordKey]; - } - } - - // Compose hosts with security scheme (preconfig stores them separately) - foreach (['imap', 'smtp', 'sieve'] as $proto) { - $hostKey = "ident_switch.form.{$proto}.host"; - $secKey = "ident_switch.form.{$proto}.security"; - if (empty($data["{$proto}.host"]) && !empty($record[$hostKey])) { - $security = $record[$secKey] ?? ''; - $data["{$proto}.host"] = self::compose_host_scheme($record[$hostKey], $security); - } - } - } - - /** - * Resolve IMAP password for connection testing. - * - * If the password is the sentinel (unchanged), decrypt from DB. - * Otherwise return the raw password as-is. - * - * @param rcmail $rc Roundcube instance. - * @param int $iid Identity ID. - * @param string|null $pass Raw password from POST. - * @return string Decrypted or raw password for testing. - */ - private function resolve_password_for_test(rcmail $rc, int $iid, ?string $pass): string - { - if ($pass !== self::PASSWORD_SENTINEL) { - return $pass ?? ''; - } - - $sql = 'SELECT password FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $iid, $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if (!$r || empty($r['password'])) { - return ''; - } - - $decrypted = $rc->decrypt($r['password']); - return ($decrypted !== false) ? $decrypted : ''; - } - - /** - * Validate all plugin form field values from POST data. - * - * Checks length constraints, port ranges, and parses flags. - * Also retrieves and includes the password. - * - * @return array Validated data with field values and optional 'err' key on failure. - */ - public function validate(): array - { - $retVal = []; - - $retVal['label'] = self::get_field_value('common', 'label'); - if (strlen($retVal['label'] ?? '') > 32) { - $retVal['err'] = 'label.long'; - return $retVal; - } - - // Validate and compose IMAP host with security scheme - $imapBareHost = self::get_field_value('imap', 'host'); - $retVal['imap.host'] = $imapBareHost; - $imapSecurity = self::get_field_value('imap', 'security') ?? ''; - $retVal['imap.host'] = self::compose_host_scheme($retVal['imap.host'], $imapSecurity); - if (strlen($retVal['imap.host'] ?? '') > 64) { - $retVal['err'] = 'host.long'; - return $retVal; - } - - $retVal['imap.port'] = self::get_field_value('imap', 'port'); - if ($retVal['imap.port'] && !ctype_digit($retVal['imap.port'])) { - $retVal['err'] = 'port.num'; - return $retVal; - } - if ($retVal['imap.port'] && ($retVal['imap.port'] <= 0 || $retVal['imap.port'] > 65535)) { - $retVal['err'] = 'port.range'; - return $retVal; - } - - $delimMode = self::get_field_value('imap', 'delimiter_mode'); - if ($delimMode === 'manual') { - $retVal['imap.delimiter'] = self::get_field_value('imap', 'delimiter'); - if (strlen($retVal['imap.delimiter'] ?? '') > 1) { - $retVal['err'] = 'delim.long'; - return $retVal; - } - } else { - $retVal['imap.delimiter'] = null; - } - - $retVal['imap.user'] = self::get_field_value('imap', 'username'); - if (strlen($retVal['imap.user'] ?? '') > 64) { - $retVal['err'] = 'user.long'; - return $retVal; - } - - // Validate and compose SMTP host with security scheme (fallback to IMAP host) - $retVal['smtp.host'] = self::get_field_value('smtp', 'host') ?: $imapBareHost; - $smtpSecurity = self::get_field_value('smtp', 'security') ?? ''; - $retVal['smtp.host'] = self::compose_host_scheme($retVal['smtp.host'], $smtpSecurity); - if (strlen($retVal['smtp.host'] ?? '') > 64) { - $retVal['err'] = 'host.long'; - return $retVal; - } - - $retVal['smtp.port'] = self::get_field_value('smtp', 'port'); - if ($retVal['smtp.port'] && !ctype_digit($retVal['smtp.port'])) { - $retVal['err'] = 'port.num'; - return $retVal; - } - if ($retVal['smtp.port'] && ($retVal['smtp.port'] <= 0 || $retVal['smtp.port'] > 65535)) { - $retVal['err'] = 'port.range'; - return $retVal; - } - - $retVal['smtp.auth'] = self::get_field_value('smtp', 'auth') ?? (string)ident_switch::SMTP_AUTH_IMAP; - if (!ctype_digit($retVal['smtp.auth'])) { - $retVal['err'] = 'auth.num'; - return $retVal; - } - - // Custom SMTP credentials - if ((int)$retVal['smtp.auth'] === ident_switch::SMTP_AUTH_CUSTOM) { - $retVal['smtp.user'] = self::get_field_value('smtp', 'username'); - if (strlen($retVal['smtp.user'] ?? '') > 64) { - $retVal['err'] = 'user.long'; - return $retVal; - } - $retVal['smtp.pass'] = self::get_field_value('smtp', 'password', false, true); - } else { - $retVal['smtp.user'] = null; - $retVal['smtp.pass'] = null; - } - - // Validate and compose Sieve host with security scheme (fallback to IMAP host) - $retVal['sieve.host'] = self::get_field_value('sieve', 'host') ?: $imapBareHost; - $sieveSecurity = self::get_field_value('sieve', 'security') ?? ''; - $retVal['sieve.host'] = self::compose_host_scheme($retVal['sieve.host'], $sieveSecurity); - if (strlen($retVal['sieve.host'] ?? '') > 64) { - $retVal['err'] = 'host.long'; - return $retVal; - } - - $retVal['sieve.port'] = self::get_field_value('sieve', 'port'); - if ($retVal['sieve.port'] && !ctype_digit($retVal['sieve.port'])) { - $retVal['err'] = 'port.num'; - return $retVal; - } - if ($retVal['sieve.port'] && ($retVal['sieve.port'] <= 0 || $retVal['sieve.port'] > 65535)) { - $retVal['err'] = 'port.range'; - return $retVal; - } - - $retVal['sieve.auth'] = self::get_field_value('sieve', 'auth') ?? (string)ident_switch::SIEVE_AUTH_IMAP; - if (!ctype_digit($retVal['sieve.auth'])) { - $retVal['err'] = 'auth.num'; - return $retVal; - } - - // Custom Sieve credentials - if ((int)$retVal['sieve.auth'] === ident_switch::SIEVE_AUTH_CUSTOM) { - $retVal['sieve.user'] = self::get_field_value('sieve', 'username'); - if (strlen($retVal['sieve.user'] ?? '') > 64) { - $retVal['err'] = 'user.long'; - return $retVal; - } - $retVal['sieve.pass'] = self::get_field_value('sieve', 'password', false, true); - } else { - $retVal['sieve.user'] = null; - $retVal['sieve.pass'] = null; - } - - // Notification settings - $retVal['notify.check'] = self::get_field_value('notify', 'check', false) ? 1 : 0; - - $notifyBasic = self::get_field_value('notify', 'basic'); - $retVal['notify.basic'] = ($notifyBasic !== null && $notifyBasic !== '') ? (int)$notifyBasic : null; - - $notifySound = self::get_field_value('notify', 'sound'); - $retVal['notify.sound'] = ($notifySound !== null && $notifySound !== '') ? (int)$notifySound : null; - - $notifyDesktop = self::get_field_value('notify', 'desktop'); - $retVal['notify.desktop'] = ($notifyDesktop !== null && $notifyDesktop !== '') ? (int)$notifyDesktop : null; - - // Get also password - $retVal['imap.pass'] = self::get_field_value('imap', 'password', false, true); - - // Flags: only enabled, security is now in host field scheme - $retVal['flags'] = ident_switch::DB_ENABLED; - - return $retVal; - } - - /** - * Retrieve a form field value from POST input. - * - * @param string $section Form section name (common, imap, smtp). - * @param string $field Field name within the section. - * @param bool $trim Whether to trim and nullify empty values. - * @param bool $html Whether to allow HTML in the value. - * @return string|null The field value, or null if empty and trimmed. - */ - public static function get_field_value(string $section, string $field, bool $trim = true, bool $html = false): ?string - { - $retVal = rcube_utils::get_input_value( - "_ident_switch_form_{$section}_{$field}", - rcube_utils::INPUT_POST, - $html - ); - if (!$trim) { - return $retVal; - } - - return ident_switch::ntrim($retVal); - } - - /** - * Persist plugin field values to the database (insert or update). - * - * Handles password encryption and determines whether to create a new - * record or update an existing one. - * - * @param rcmail $rc Roundcube instance for DB access and encryption. - * @param array $data Validated field data including 'id' (identity_id). - * @return bool True if a query was executed, false otherwise. - */ - public function save(rcmail $rc, array $data): bool - { - $sql = 'SELECT id, password, smtp_password, sieve_password FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $data['id'], $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if ($r) { - // Record already exists, will update it - $sql = 'UPDATE ' . - $rc->db->table_name(ident_switch::TABLE) . - ' SET flags = ?, parent_id = ?, label = ?, imap_host = ?, imap_port = ?, imap_delimiter = ?, username = ?, password = ?,' . - ' smtp_host = ?, smtp_port = ?, smtp_auth = ?, smtp_username = ?, smtp_password = ?,' . - ' sieve_host = ?, sieve_port = ?, sieve_auth = ?, sieve_username = ?, sieve_password = ?,' . - ' notify_check = ?, notify_basic = ?, notify_sound = ?, notify_desktop = ?,' . - ' user_id = ?, iid = ?' . - ' WHERE id = ?'; - } elseif ($data['flags'] & ident_switch::DB_ENABLED) { - // No record exists, create new one - $sql = 'INSERT INTO ' . - $rc->db->table_name(ident_switch::TABLE) . - '(flags, parent_id, label, imap_host, imap_port, imap_delimiter, username, password,' . - ' smtp_host, smtp_port, smtp_auth, smtp_username, smtp_password,' . - ' sieve_host, sieve_port, sieve_auth, sieve_username, sieve_password,' . - ' notify_check, notify_basic, notify_sound, notify_desktop,' . - ' user_id, iid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; - } else { - return false; - } - - // Handle IMAP password: sentinel means unchanged, empty means clear, else encrypt - if ($data['imap.pass'] === self::PASSWORD_SENTINEL) { - $data['imap.pass'] = $r['password'] ?? null; - } else { - $data['imap.pass'] = $data['imap.pass'] ? $rc->encrypt($data['imap.pass']) : null; - } - - // Handle SMTP password - if ($data['smtp.pass'] === self::PASSWORD_SENTINEL) { - $data['smtp.pass'] = $r['smtp_password'] ?? null; - } else { - $data['smtp.pass'] = $data['smtp.pass'] ? $rc->encrypt($data['smtp.pass']) : null; - } - - // Handle Sieve password - if ($data['sieve.pass'] === self::PASSWORD_SENTINEL) { - $data['sieve.pass'] = $r['sieve_password'] ?? null; - } else { - $data['sieve.pass'] = $data['sieve.pass'] ? $rc->encrypt($data['sieve.pass']) : null; - } - - $rc->db->query( - $sql, - $data['flags'], - null, // parent_id: NULL for separate accounts - $data['label'], - $data['imap.host'], - $data['imap.port'], - $data['imap.delimiter'], - $data['imap.user'], - $data['imap.pass'], - $data['smtp.host'], - $data['smtp.port'], - $data['smtp.auth'], - $data['smtp.user'], - $data['smtp.pass'], - $data['sieve.host'], - $data['sieve.port'], - $data['sieve.auth'], - $data['sieve.user'], - $data['sieve.pass'], - $data['notify.check'] ?? 1, - $data['notify.basic'] ?? null, - $data['notify.sound'] ?? null, - $data['notify.desktop'] ?? null, - $rc->user->ID, - $data['id'], - $r['id'] ?? null - ); - - return true; - } - - /** - * Save an alias link between an identity and a parent account. - * - * Creates or updates an ident_switch record with parent_id set and - * all server fields cleared (alias uses parent's config). - * - * @param int $iid Identity ID of the alias. - * @param int $parentId ident_switch.id of the parent account. - */ - private function save_alias(int $iid, int $parentId): void - { - $rc = rcmail::get_instance(); - - // Validate that parent_id exists and belongs to this user - $sql = 'SELECT id FROM ' . $rc->db->table_name(ident_switch::TABLE) - . ' WHERE id = ? AND user_id = ? AND parent_id IS NULL'; - $q = $rc->db->query($sql, $parentId, $rc->user->ID); - if (!$rc->db->fetch_assoc($q)) { - ident_switch::write_log("Alias save: parent account with id={$parentId} not found for user."); - return; - } - - $label = self::get_field_value('common', 'label'); - - // Check if record already exists - $sql = 'SELECT id FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $iid, $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - - if ($r) { - $sql = 'UPDATE ' . $rc->db->table_name(ident_switch::TABLE) - . ' SET flags = ?, parent_id = ?, label = ?,' - . ' imap_host = NULL, imap_port = NULL, imap_delimiter = NULL,' - . ' username = NULL, password = NULL,' - . ' smtp_host = NULL, smtp_port = NULL, smtp_auth = 1, smtp_username = NULL, smtp_password = NULL,' - . ' sieve_host = NULL, sieve_port = NULL, sieve_auth = 1, sieve_username = NULL, sieve_password = NULL,' - . ' notify_check = 0, notify_basic = NULL, notify_sound = NULL, notify_desktop = NULL' - . ' WHERE id = ?'; - $rc->db->query($sql, ident_switch::DB_ENABLED, $parentId, $label, $r['id']); - } else { - $sql = 'INSERT INTO ' . $rc->db->table_name(ident_switch::TABLE) - . ' (flags, parent_id, label, user_id, iid) VALUES (?, ?, ?, ?, ?)'; - $rc->db->query($sql, ident_switch::DB_ENABLED, $parentId, $label, $rc->user->ID, $iid); - } - - ident_switch::write_log("Saved alias link: identity {$iid} → parent account {$parentId}."); - } - - /** - * Disable the ident_switch flag for a given identity. - * - * @param int $iid Identity ID to disable. - */ - public function disable(int $iid): void - { - $rc = rcmail::get_instance(); - - $sql = 'UPDATE ' . $rc->db->table_name(ident_switch::TABLE) . ' SET flags = flags & ? WHERE iid = ? AND user_id = ?'; - $rc->db->query($sql, ~ident_switch::DB_ENABLED, $iid, $rc->user->ID); - } + /** @var string Sentinel value placed in password fields to indicate an existing password. */ + private const PASSWORD_SENTINEL = '__IDENT_SWITCH_UNCHANGED__'; + + private ident_switch $plugin; + + /** + * Constructor. + * + * @param ident_switch $plugin Parent plugin instance. + */ + public function __construct(ident_switch $plugin) + { + $this->plugin = $plugin; + } + + /** + * Build the common form fields for identity settings. + * + * @param array $record Identity record data used for placeholders. + * @param boolean $domainAllowed Whether the identity domain has a matching preconfig. + * @param string $warningHtml HTML warning message when domain is not allowed. + * @return array Form field definitions for mode select, label, and readonly. + */ + public function get_common_fields(array &$record, bool $domainAllowed = true, string $warningHtml = ''): array + { + $prefix = 'ident_switch.form.common.'; + $rc = rcmail::get_instance(); + + // Build mode select: Primary / Alias of X / Separate account + $modeSelect = new html_select([ + 'name' => "_{$prefix}mode", + 'onchange' => 'plugin_switchIdent_mode_onChange(this.value);', + ]); + $modeSelect->add($this->plugin->gettext('form.common.mode.primary'), 'primary'); + + // Add available accounts as alias targets + $accounts = $this->get_available_accounts($rc, $record['identity_id'] ?? null); + foreach ($accounts as $acc) { + $label = $acc['label'] ?: $acc['email']; + $modeSelect->add($label, 'alias:' . $acc['id']); + } + + // Only offer "Separate account" when domain is allowed + if ($domainAllowed) { + $modeSelect->add($this->plugin->gettext('form.common.mode.separate'), 'separate'); + } + + $currentMode = $record["{$prefix}mode"] ?? 'primary'; + $modeHtml = $modeSelect->show($currentMode) + . html::span( + ['class' => 'form-text'], + rcube::Q($this->plugin->gettext('form.common.mode.hint')) + ) + . $warningHtml; + + return [ + $prefix . 'mode' => ['value' => $modeHtml], + $prefix . 'readonly' => ['type' => 'hidden'], + ]; + } + + /** + * Get all separate accounts available as alias targets. + * + * @param rcmail $rc Roundcube instance. + * @param integer|null $excludeIid Identity ID to exclude (the identity being edited). + * @return array List of account records with id, label, username, email. + */ + private function get_available_accounts(rcmail $rc, ?int $excludeIid): array + { + $sql = 'SELECT isw.id, isw.label, isw.username, ii.email' + . ' FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' isw' + . ' INNER JOIN ' . $rc->db->table_name('identities') . ' ii ON isw.iid = ii.identity_id' + . ' WHERE isw.user_id = ? AND isw.flags & ? > 0 AND isw.parent_id IS NULL'; + + $params = [$rc->user->ID, ident_switch::DB_ENABLED]; + + if ($excludeIid !== null) { + $sql .= ' AND isw.iid != ?'; + $params[] = $excludeIid; + } + + $q = $rc->db->query($sql, ...$params); + $accounts = []; + while ($r = $rc->db->fetch_assoc($q)) { + $accounts[] = $r; + } + return $accounts; + } + + /** + * Build the separate account header fields (label). + * + * @param array $record Identity record data. + * @return array Form field definitions for label. + */ + public function get_separate_fields(array &$record): array + { + $prefix = 'ident_switch.form.common.'; + + $labelInput = new html_inputfield([ + 'name' => "_{$prefix}label", + 'type' => 'text', + 'size' => 32, + 'placeholder' => $record['email'] ?? '', + ]); + $labelHtml = $labelInput->show($record["{$prefix}label"] ?? '') + . html::span( + ['class' => 'form-text'], + rcube::Q($this->plugin->gettext('form.common.label.hint')) + ); + + return [ + $prefix . 'label' => ['value' => $labelHtml], + ]; + } + + /** + * Build the IMAP form fields for identity settings. + * + * @param array $record Identity record data used for placeholders. + * @return array Form field definitions for IMAP host, port, security, username, password, delimiter. + */ + public function get_imap_fields(array &$record): array + { + $prefix = 'ident_switch.form.imap.'; + return [ + $prefix . 'host' => ['type' => 'text', 'size' => 64, 'placeholder' => 'localhost'], + $prefix . 'security' => ['value' => $this->build_security_select($prefix, $record, 'ssl')], + $prefix . 'port' => ['type' => 'text', 'size' => 5, 'placeholder' => 993], + $prefix . 'username' => ['type' => 'text', 'size' => 64, 'placeholder' => $record['email'] ?? ''], + $prefix . 'password' => ['type' => 'password', 'size' => 64, 'autocomplete' => 'off'], + $prefix . 'delimiter' => ['value' => $this->build_delimiter_field($prefix, $record)], + ]; + } + + /** + * Build the SMTP form fields for identity settings. + * + * @param array $record Identity record data used for default auth type selection. + * @return array Form field definitions for SMTP host, port, and auth type. + */ + public function get_smtp_fields(array &$record): array + { + $prefix = 'ident_switch.form.smtp.'; + + $authType = new html_select(['name' => "_{$prefix}auth"]); + $authType->add($this->plugin->gettext('form.smtp.auth.imap'), ident_switch::SMTP_AUTH_IMAP); + $authType->add($this->plugin->gettext('form.smtp.auth.none'), ident_switch::SMTP_AUTH_NONE); + $authType->add($this->plugin->gettext('form.smtp.auth.custom'), ident_switch::SMTP_AUTH_CUSTOM); + + // Cast to int: html_select::show() uses strict comparison (===), + // option values are integers (constants), but POST/DB may return strings. + $authVal = isset($record[$prefix . 'auth']) ? (int)$record[$prefix . 'auth'] : null; + + return [ + $prefix . 'host' => ['type' => 'text', 'size' => 64, 'placeholder' => 'localhost'], + $prefix . 'security' => ['value' => $this->build_security_select($prefix, $record, 'tls')], + $prefix . 'port' => ['type' => 'text', 'size' => 5, 'placeholder' => 587], + $prefix . 'auth' => ['value' => $authType->show($authVal !== null ? [$authVal] : [])], + $prefix . 'username' => ['type' => 'text', 'size' => 64, 'autocomplete' => 'off'], + $prefix . 'password' => ['type' => 'password', 'size' => 64, 'autocomplete' => 'off'], + ]; + } + + /** + * Build the Sieve (managesieve) form fields for identity settings. + * + * @param array $record Identity record data used for default auth type selection. + * @return array Form field definitions for Sieve host, port, and auth type. + */ + public function get_sieve_fields(array &$record): array + { + $prefix = 'ident_switch.form.sieve.'; + + $authType = new html_select(['name' => "_{$prefix}auth"]); + $authType->add($this->plugin->gettext('form.sieve.auth.imap'), ident_switch::SIEVE_AUTH_IMAP); + $authType->add($this->plugin->gettext('form.sieve.auth.none'), ident_switch::SIEVE_AUTH_NONE); + $authType->add($this->plugin->gettext('form.sieve.auth.custom'), ident_switch::SIEVE_AUTH_CUSTOM); + + // Cast to int: html_select::show() uses strict comparison (===) + $authVal = isset($record[$prefix . 'auth']) ? (int)$record[$prefix . 'auth'] : null; + + return [ + $prefix . 'host' => ['type' => 'text', 'size' => 64, 'placeholder' => 'localhost'], + $prefix . 'security' => ['value' => $this->build_security_select($prefix, $record, 'tls')], + $prefix . 'port' => ['type' => 'text', 'size' => 5, 'placeholder' => 4190], + $prefix . 'auth' => ['value' => $authType->show($authVal !== null ? [$authVal] : [])], + $prefix . 'username' => ['type' => 'text', 'size' => 64, 'autocomplete' => 'off'], + $prefix . 'password' => ['type' => 'password', 'size' => 64, 'autocomplete' => 'off'], + ]; + } + + /** + * Build the notification form fields for identity settings. + * + * Shows the actual newmail_notifier default value in each tri-state select. + * Selecting the default option stores NULL so changes to global defaults propagate. + * + * @param array $record Identity record data used for default values. + * @return array Form field definitions for notification preferences. + */ + public function get_notification_fields(array &$record): array + { + $rc = rcmail::get_instance(); + $prefix = 'ident_switch.form.notify.'; + + $defaultKeys = [ + 'basic' => 'newmail_notifier_basic', + 'sound' => 'newmail_notifier_sound', + 'desktop' => 'newmail_notifier_desktop', + ]; + + $triState = function (string $name) use ($prefix, &$record, $rc, $defaultKeys): string { + $defaultVal = $rc->config->get($defaultKeys[$name] ?? '', false); + $defaultLabel = $defaultVal + ? $this->plugin->gettext('form.notify.on') + : $this->plugin->gettext('form.notify.off'); + $defaultLabel .= ' (' . strtolower($this->plugin->gettext('form.notify.default')) . ')'; + + $select = new html_select(['name' => "_{$prefix}{$name}"]); + $select->add($defaultLabel, ''); + $select->add($this->plugin->gettext('form.notify.on'), '1'); + $select->add($this->plugin->gettext('form.notify.off'), '0'); + return $select->show($record[$prefix . $name] ?? ''); + }; + + return [ + $prefix . 'check' => ['type' => 'checkbox'], + $prefix . 'basic' => ['value' => $triState('basic')], + $prefix . 'sound' => ['value' => $triState('sound')], + $prefix . 'desktop' => ['value' => $triState('desktop')], + ]; + } + + /** + * Build a security dropdown select for a protocol section. + * + * @param string $prefix Form field prefix (e.g. 'ident_switch.form.imap.'). + * @param array $record Identity record data. + * @param string $default Default security value ('', 'tls', 'ssl'). + * @return string Rendered HTML select element. + */ + private function build_security_select(string $prefix, array &$record, string $default): string + { + $select = new html_select(['name' => "_{$prefix}security"]); + $select->add($this->plugin->gettext('form.security.none'), ''); + $select->add($this->plugin->gettext('form.security.starttls'), 'tls'); + $select->add($this->plugin->gettext('form.security.ssl'), 'ssl'); + + $current = $record[$prefix . 'security'] ?? $default; + $proto = str_replace('ident_switch.form.', '', rtrim($prefix, '.')); + $hidden = $current !== '' ? ' style="display:none"' : ''; + $warning = '
' + . rcube::Q($this->plugin->gettext('form.security.none_warning')) + . '
'; + + return $select->show($current) . $warning; + } + + /** + * Build the delimiter field with Auto/Manual mode select. + * + * @param string $prefix Form field prefix (e.g. 'ident_switch.form.imap.'). + * @param array $record Identity record data. + * @return string Rendered HTML select + text input. + */ + private function build_delimiter_field(string $prefix, array &$record): string + { + $delimValue = $record[$prefix . 'delimiter'] ?? ''; + $mode = ($delimValue !== '' && $delimValue !== null) ? 'manual' : 'auto'; + + $select = new html_select(['name' => "_{$prefix}delimiter_mode"]); + $select->add($this->plugin->gettext('form.imap.delimiter.auto'), 'auto'); + $select->add($this->plugin->gettext('form.imap.delimiter.manual'), 'manual'); + + $hidden = $mode === 'auto' ? ' style="display:none"' : ''; + $input = new html_inputfield(['name' => "_{$prefix}delimiter", 'size' => 1]); + + return $select->show($mode) + . ' ' + . $input->show($delimValue) + . ''; + } + + /** + * Check if a domain is allowed for ident_switch configuration. + * + * When 'ident_switch.preconfig_only' is enabled, only domains with + * a matching preconfig entry are allowed. + * + * @param string $email Email address to check. + * @return boolean True if allowed, false if blocked by preconfig_only. + */ + private function is_domain_allowed(string $email): bool + { + $rc = rcmail::get_instance(); + if (!$rc->config->get('ident_switch.preconfig_only', false)) { + return true; + } + $preconfig = new IdentSwitchPreconfig($this->plugin); + return $preconfig->get($email) !== false; + } + + /** + * Pass preconfig data and settings to JS environment. + * + * Parses each domain's protocol URLs into host/security/port components + * so the client can dynamically populate form fields on email change. + * + * @param rcmail $rc Roundcube instance. + */ + private function pass_preconfig_to_js(rcmail $rc): void + { + $this->plugin->load_config(); + $allPreconfig = $rc->config->get('ident_switch.preconfig', []); + $preconfigOnly = $rc->config->get('ident_switch.preconfig_only', false); + + $jsPreconfig = []; + foreach ($allPreconfig as $domain => $cfg) { + $entry = []; + + $protocols = [ + 'imap' => $cfg['imap_host'] ?? $cfg['host'] ?? '', + 'smtp' => $cfg['smtp_host'] ?? $cfg['host'] ?? '', + 'sieve' => $cfg['sieve_host'] ?? '', + ]; + + foreach ($protocols as $proto => $url) { + if (empty($url)) { + continue; + } + $urlArr = parse_url($url); + if (!is_array($urlArr)) { + continue; + } + $scheme = strtolower($urlArr['scheme'] ?? ''); + $entry[$proto] = [ + 'host' => $urlArr['host'] ?? '', + 'security' => in_array($scheme, ['ssl', 'tls']) ? $scheme : '', + 'port' => !empty($urlArr['port']) ? intval($urlArr['port']) : '', + ]; + } + + $entry['user'] = $cfg['user'] ?? ''; + $entry['readonly'] = !empty($cfg['readonly']); + $jsPreconfig[$domain] = $entry; + } + + $rc->output->set_env('ident_switch_preconfig', $jsPreconfig); + $rc->output->set_env('ident_switch_preconfig_only', $preconfigOnly); + $rc->output->set_env('ident_switch_warning_tpl', $this->plugin->gettext('form.preconfig_only_warning')); + } + + /** + * Delegate to ident_switch::parse_host_scheme(). + * + * @param string $host Hostname possibly prefixed with ssl:// or tls://. + */ + private static function parse_host_scheme(string $host): array + { + return ident_switch::parse_host_scheme($host); + } + + /** + * Compose a host string with scheme prefix. + * + * @param string|null $host Bare host name. + * @param string|null $security Security type ('', 'tls', 'ssl'). + * @return string|null Host with scheme prefix, or null if host is empty. + */ + private static function compose_host_scheme(?string $host, ?string $security): ?string + { + if ($host === null || $host === '') { + return $host; + } + if ($security === 'ssl' || $security === 'tls') { + return $security . '://' . $host; + } + return $host; + } + + /** + * Restore plugin form field values from POST data after a save error. + * + * When the identity save is aborted (validation or connection error), + * Roundcube re-renders the form. Core fields are preserved from POST by RC, + * but plugin fields would revert to DB values without this restoration. + * + * @param array &$record Identity record to overlay POST values onto. + */ + private function restore_post_values(array &$record): void + { + // Text and select fields (trimmed) + $fields = [ + ['common', 'label'], + ['imap', 'host'], + ['imap', 'port'], + ['imap', 'username'], + ['imap', 'delimiter'], + ['smtp', 'host'], + ['smtp', 'port'], + ['smtp', 'auth'], + ['smtp', 'username'], + ['sieve', 'host'], + ['sieve', 'port'], + ['sieve', 'auth'], + ['sieve', 'username'], + ['notify', 'basic'], + ['notify', 'sound'], + ['notify', 'desktop'], + ]; + + foreach ($fields as [$section, $field]) { + $rawVal = self::get_field_value($section, $field, false); + if ($rawVal !== null) { + $record["ident_switch.form.{$section}.{$field}"] = self::get_field_value($section, $field); + } + } + + // Security selects: empty string means "None", must not be trimmed to null + foreach (['imap', 'smtp', 'sieve'] as $proto) { + $rawVal = self::get_field_value($proto, 'security', false); + if ($rawVal !== null) { + $record["ident_switch.form.{$proto}.security"] = $rawVal; + } + } + + // Password fields (raw, no trim) + foreach (['imap', 'smtp', 'sieve'] as $proto) { + $rawVal = self::get_field_value($proto, 'password', false, true); + if ($rawVal !== null) { + $record["ident_switch.form.{$proto}.password"] = $rawVal; + } + } + + // Mode select + $modeVal = self::get_field_value('common', 'mode', false); + if ($modeVal !== null) { + $record['ident_switch.form.common.mode'] = $modeVal; + } + + // Checkbox: absent from POST means unchecked + $record['ident_switch.form.notify.check'] = !empty(self::get_field_value('notify', 'check', false)); + } + + /** + * Test IMAP, SMTP, and Sieve connections using validated form data. + * + * @param array $data Validated data from validate(). + * @param string $email Email address (fallback username). + * @param string $imapPass Raw IMAP password (not encrypted). + * @return string|null Error key on failure, null on success. + */ + private function test_connections(array $data, string $email, string $imapPass): ?string + { + $rc = rcmail::get_instance(); + + // --- IMAP test --- + $imapHostFull = $data['imap.host'] ?: 'localhost'; + $parsed = self::parse_host_scheme($imapHostFull); + $imapHost = $parsed['host']; + $imapSsl = $parsed['scheme'] ?: null; + $imapDefPort = ($imapSsl === 'ssl') ? 993 : 143; + $imapPort = $data['imap.port'] ?: $imapDefPort; + $imapUser = $data['imap.user'] ?: $email; + + $imap = new rcube_imap_generic(); + $result = $imap->connect($imapHost, $imapUser, $imapPass, [ + 'port' => (int)$imapPort, + 'ssl_mode' => $imapSsl, + 'timeout' => 10, + ]); + if (!$result) { + ident_switch::write_log("IMAP connection test failed: {$imap->error}"); + return 'imap.connect'; + } + $imap->closeConnection(); + + // --- SMTP test --- + $smtpAuth = (int)($data['smtp.auth'] ?? ident_switch::SMTP_AUTH_IMAP); + if ($smtpAuth !== ident_switch::SMTP_AUTH_NONE) { + $smtpHostFull = $data['smtp.host'] ?: 'localhost'; + $smtpParsed = self::parse_host_scheme($smtpHostFull); + $smtpDefPort = ($smtpParsed['scheme'] === 'ssl') ? 465 : 587; + $smtpPort = $data['smtp.port'] ?: $smtpDefPort; + // Compose host with scheme for rcube_smtp (expects ssl://host:port or tls://host:port) + $smtpConnHost = self::compose_host_scheme($smtpParsed['host'], $smtpParsed['scheme'] ?: null); + $smtpConnHost .= ':' . $smtpPort; + + if ($smtpAuth === ident_switch::SMTP_AUTH_CUSTOM) { + $smtpUser = $data['smtp.user'] ?: ''; + $smtpPass = $data['smtp.pass'] ?: ''; + } else { + $smtpUser = $imapUser; + $smtpPass = $imapPass; + } + + $smtp = new rcube_smtp(); + $result = $smtp->connect($smtpConnHost, null, $smtpUser, $smtpPass); + if (!$result) { + ident_switch::write_log("SMTP connection test failed"); + return 'smtp.connect'; + } + $smtp->disconnect(); + } + + // --- Sieve test (only if host is configured and auth is not None) --- + $sieveAuth = (int)($data['sieve.auth'] ?? ident_switch::SIEVE_AUTH_IMAP); + $sieveHostFull = $data['sieve.host'] ?? ''; + if (!empty($sieveHostFull) && $sieveAuth !== ident_switch::SIEVE_AUTH_NONE) { + $sieveParsed = self::parse_host_scheme($sieveHostFull); + $sievePort = $data['sieve.port'] ?: 4190; + $useTls = ($sieveParsed['scheme'] === 'tls'); + $sieveHost = $sieveParsed['host']; + if ($sieveParsed['scheme'] === 'ssl') { + $sieveHost = 'ssl://' . $sieveHost; + } + + if ($sieveAuth === ident_switch::SIEVE_AUTH_CUSTOM) { + $sieveUser = $data['sieve.user'] ?: ''; + $sievePass = $data['sieve.pass'] ?: ''; + } else { + $sieveUser = $imapUser; + $sievePass = $imapPass; + } + + if (class_exists('rcube_sieve')) { + $sieve = new rcube_sieve($sieveUser, $sievePass, $sieveHost, (int)$sievePort, null, $useTls); + if ($sieve->error()) { + ident_switch::write_log("Sieve connection test failed (error code: {$sieve->error()})"); + return 'sieve.connect'; + } + } + } + + return null; + } + + /** + * Handle identity_form hook: add plugin-specific fields to the identity editor. + * + * Loads existing account data from the database (if editing) or applies + * preconfigured settings, then adds Common/IMAP/SMTP/Sieve sections to the form. + * + * @param array $args Hook arguments containing 'record' with identity data. + * @param IdentSwitchPreconfig $preconfig Preconfiguration handler. + * @return array Modified hook arguments with added form sections. + */ + public function on_identity_form(array $args, IdentSwitchPreconfig $preconfig): array + { + $rc = rcmail::get_instance(); + + // When creating a new identity, record may be null + if ($args['record'] === null) { + $args['record'] = []; + } + + // Do not show options for default identity + if (!empty($args['record']['email']) && strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { + return $args; + } + + $this->plugin->add_texts('localization'); + + $preconfigOnly = $rc->config->get('ident_switch.preconfig_only', false); + $domainAllowed = empty($args['record']['email']) || $this->is_domain_allowed($args['record']['email']); + + // Build domain warning HTML (included in mode field) + $warningHtml = ''; + if ($preconfigOnly) { + $email = $args['record']['email'] ?? ''; + $domain = ''; + if (!empty($email) && str_contains($email, '@')) { + $domain = substr($email, strpos($email, '@') + 1); + } + $warningHtml = html::div( + ['class' => 'boxwarning', 'id' => 'ident-switch-domain-warning', + 'style' => $domainAllowed ? 'display:none' : ''], + rcube::Q(sprintf($this->plugin->gettext('form.preconfig_only_warning'), $domain)) + ); + } + + // Pass preconfig data to JS for dynamic form updates + $this->pass_preconfig_to_js($rc); + + $row = null; + if (isset($args['record']['identity_id'])) { + $sql = 'SELECT * FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $args['record']['identity_id'], $rc->user->ID); + $row = $rc->db->fetch_assoc($q); + } + + $record = &$args['record']; + + // Tell JS whether this identity has an existing ident_switch record + $rc->output->set_env('ident_switch_has_record', !empty($row)); + + // Load data if exists + if ($row) { + $dbToForm = [ + 'label' => 'common.label', + 'imap_host' => 'imap.host', + 'imap_port' => 'imap.port', + 'imap_delimiter' => 'imap.delimiter', + 'username' => 'imap.username', + 'password' => 'imap.password', + 'smtp_host' => 'smtp.host', + 'smtp_port' => 'smtp.port', + 'smtp_auth' => 'smtp.auth', + 'smtp_username' => 'smtp.username', + 'smtp_password' => 'smtp.password', + 'sieve_host' => 'sieve.host', + 'sieve_port' => 'sieve.port', + 'sieve_auth' => 'sieve.auth', + 'sieve_username' => 'sieve.username', + 'sieve_password' => 'sieve.password', + 'notify_check' => 'notify.check', + 'notify_basic' => 'notify.basic', + 'notify_sound' => 'notify.sound', + 'notify_desktop' => 'notify.desktop', + ]; + foreach ($row as $k => $v) { + if (isset($dbToForm[$k])) { + $record['ident_switch.form.' . $dbToForm[$k]] = $v; + } + } + + // Replace encrypted passwords with sentinel (shows dots in form, detectable on save) + foreach (['imap.password', 'smtp.password', 'sieve.password'] as $passKey) { + $fullKey = 'ident_switch.form.' . $passKey; + if (!empty($record[$fullKey])) { + $record[$fullKey] = self::PASSWORD_SENTINEL; + } + } + + // Determine mode: alias (has parent_id) or separate account + if ($row['parent_id'] !== null) { + $record['ident_switch.form.common.mode'] = 'alias:' . $row['parent_id']; + } elseif ($row['flags'] & ident_switch::DB_ENABLED) { + $record['ident_switch.form.common.mode'] = 'separate'; + } else { + $record['ident_switch.form.common.mode'] = 'primary'; + } + + // Parse host schemes into separate security fields + foreach (['imap', 'smtp', 'sieve'] as $proto) { + $key = "ident_switch.form.{$proto}.host"; + $hostVal = $record[$key] ?? ''; + if ($hostVal !== '') { + $parsed = self::parse_host_scheme($hostVal); + $record[$key] = $parsed['host']; + $record["ident_switch.form.{$proto}.security"] = $parsed['scheme']; + } + } + + // Backward compat: if IMAP host had no scheme but TLS flag was set + if (empty($record['ident_switch.form.imap.security']) && ($row['flags'] & ident_switch::DB_SECURE_IMAP_TLS)) { + $record['ident_switch.form.imap.security'] = 'tls'; + } + + // Set readonly if needed + $cfg = $preconfig->get($record['email']); + if (is_array($cfg) && ($cfg['readonly'] ?? false)) { + $record['ident_switch.form.common.readonly'] = 1; + if (in_array(strtoupper($cfg['user'] ?? ''), ['EMAIL', 'MBOX'])) { + $record['ident_switch.form.common.readonly'] = 2; + } + // Override delimiter from preconfig (auto-detect if not specified) + $record['ident_switch.form.imap.delimiter'] = $cfg['delimiter'] ?? null; + } + } else { + $preconfig->apply($record); + } + + // Restore POST values when form is re-displayed after a save error + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $this->restore_post_values($record); + } + + $args['form']['ident_switch.common'] = [ + 'name' => $this->plugin->gettext('form.common.general'), + 'content' => $this->get_common_fields($record, $domainAllowed, $warningHtml), + ]; + $args['form']['ident_switch.separate'] = [ + 'name' => $this->plugin->gettext('form.caption'), + 'content' => $this->get_separate_fields($record), + ]; + $args['form']['ident_switch.imap'] = [ + 'name' => $this->plugin->gettext('form.imap.caption'), + 'content' => $this->get_imap_fields($record), + ]; + $args['form']['ident_switch.smtp'] = [ + 'name' => $this->plugin->gettext('form.smtp.caption'), + 'content' => $this->get_smtp_fields($record), + ]; + $args['form']['ident_switch.sieve'] = [ + 'name' => $this->plugin->gettext('form.sieve.caption'), + 'content' => $this->get_sieve_fields($record), + ]; + if (!$rc->config->get('ident_switch.check_mail', true)) { + // Admin disabled background mail checking + } elseif ($rc->plugins->get_plugin('newmail_notifier')) { + $args['form']['ident_switch.notify'] = [ + 'name' => $this->plugin->gettext('form.notify.caption'), + 'content' => $this->get_notification_fields($record), + ]; + } elseif (!$rc->config->get('ident_switch.hide_notifier_warning', false)) { + $args['form']['ident_switch.notify'] = [ + 'name' => $this->plugin->gettext('form.notify.caption'), + 'content' => html::div( + ['class' => 'boxinformation'], + rcube::Q($this->plugin->gettext('form.notify.requires_newmail_notifier')) + ), + ]; + } + + return $args; + } + + /** + * Handle identity_update hook: validate and save plugin fields on identity edit. + * + * @param array $args Hook arguments containing 'id' and 'record' with identity data. + * @return array Modified hook arguments, with 'abort' set on validation failure. + */ + public function on_identity_update(array $args): array + { + $rc = rcmail::get_instance(); + + // Do not do anything for default identity + if (strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { + return $args; + } + + $mode = self::get_field_value('common', 'mode', false) ?? 'primary'; + + // Block separate mode for non-preconfigured domains (alias and primary are always allowed) + if (!$this->is_domain_allowed($args['record']['email']) && $mode === 'separate') { + $this->disable($args['id']); + return $args; + } + + if ($mode === 'primary') { + $this->disable($args['id']); + return $args; + } + + if (str_starts_with($mode, 'alias:')) { + $parentId = (int)substr($mode, 6); + $this->save_alias($args['id'], $parentId); + return $args; + } + + // mode === 'separate': full validation + save + $data = $this->validate(); + if (!empty($data['err'])) { + $this->plugin->add_texts('localization'); + $args['abort'] = true; + $args['result'] = false; + $args['message'] = 'ident_switch.err.' . $data['err']; + return $args; + } + + $this->apply_readonly_preconfig($data, $args['record']['email']); + + // Resolve sentinel passwords to actual passwords for connection testing + $testPass = $this->resolve_password_for_test($rc, $args['id'], $data['imap.pass']); + + // Test connections before saving + $connErr = $this->test_connections($data, $args['record']['email'], $testPass); + if ($connErr) { + $this->plugin->add_texts('localization'); + $args['abort'] = true; + $args['result'] = false; + $args['message'] = 'ident_switch.err.' . $connErr; + return $args; + } + + $data['id'] = $args['id']; + $this->save($rc, $data); + + return $args; + } + + /** + * Handle identity_create hook: validate plugin fields before identity creation. + * + * Stores validated data in session since identity_id is not yet available. + * + * @param array $args Hook arguments containing 'record' with identity data. + * @return array Modified hook arguments, with 'abort' set on validation failure. + */ + public function on_identity_create(array $args): array + { + $rc = rcmail::get_instance(); + + // Do not do anything for default identity + if (strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { + return $args; + } + + $mode = self::get_field_value('common', 'mode', false) ?? 'primary'; + + // Block separate mode for non-preconfigured domains (alias and primary are always allowed) + if (!$this->is_domain_allowed($args['record']['email']) && $mode === 'separate') { + return $args; + } + + if ($mode === 'primary') { + return $args; + } + + if (str_starts_with($mode, 'alias:')) { + $parentId = (int)substr($mode, 6); + $_SESSION['createData' . ident_switch::MY_POSTFIX] = [ + 'mode' => 'alias', + 'parent_id' => $parentId, + 'label' => self::get_field_value('common', 'label'), + ]; + return $args; + } + + // mode === 'separate': full validation + $data = $this->validate(); + if (!empty($data['err'])) { + $this->plugin->add_texts('localization'); + $args['abort'] = true; + $args['result'] = false; + $args['message'] = 'ident_switch.err.' . $data['err']; + return $args; + } + + $this->apply_readonly_preconfig($data, $args['record']['email']); + + // For new identities, sentinel should not appear, but handle defensively + $testPass = ($data['imap.pass'] === self::PASSWORD_SENTINEL) ? '' : $data['imap.pass']; + + // Test connections before saving + $connErr = $this->test_connections($data, $args['record']['email'], $testPass); + if ($connErr) { + $this->plugin->add_texts('localization'); + $args['abort'] = true; + $args['result'] = false; + $args['message'] = 'ident_switch.err.' . $connErr; + return $args; + } + + // Save data for _after (cannot pass with $args) + $_SESSION['createData' . ident_switch::MY_POSTFIX] = $data; + + return $args; + } + + /** + * Handle identity_create_after hook: persist plugin data after identity creation. + * + * Retrieves validated data from session and saves it with the new identity_id. + * + * @param array $args Hook arguments containing 'id' (new identity_id) and 'record'. + * @return array Unmodified hook arguments. + */ + public function on_identity_create_after(array $args): array + { + $rc = rcmail::get_instance(); + + // Do not do anything for default identity + if (strcasecmp($args['record']['email'], $rc->user->data['username']) === 0) { + return $args; + } + + $data = $_SESSION['createData' . ident_switch::MY_POSTFIX] ?? null; + + unset($_SESSION['createData' . ident_switch::MY_POSTFIX]); + if (!$data || count($data) === 0) { + ident_switch::write_log("Object with ident_switch values not found in session for ID = {$args['id']}."); + } elseif (($data['mode'] ?? '') === 'alias') { + $this->save_alias($args['id'], $data['parent_id']); + } else { + $data['id'] = $args['id']; + $this->save($rc, $data); + } + + return $args; + } + + /** + * Handle identity_delete hook: remove plugin data when an identity is deleted. + * + * @param array $args Hook arguments containing 'id' of the identity being deleted. + * @return array Unmodified hook arguments. + */ + public function on_identity_delete(array $args): array + { + $rc = rcmail::get_instance(); + + $sql = 'DELETE FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $args['id'], $rc->user->ID); + + if ($rc->db->affected_rows($q)) { + ident_switch::write_log("Deleted associated information for identity with ID = {$args['id']}."); + } + + return $args; + } + + /** + * Fill in missing validated data from preconfig when identity is readonly. + * + * When a preconfig has readonly=true, form fields are disabled in the + * browser and not submitted via POST. This fills the missing values + * from the preconfig on the server side. + * + * @param array $data Validated data from validate(), modified in place. + * @param string $email Email address of the identity. + */ + private function apply_readonly_preconfig(array &$data, string $email): void + { + $preconfig = new IdentSwitchPreconfig($this->plugin); + $cfg = $preconfig->get($email); + if (!is_array($cfg) || empty($cfg['readonly'])) { + return; + } + + // Build temporary record to get preconfig values + $record = ['email' => $email]; + $preconfig->apply($record); + + // Map simple preconfig record keys to validated data keys + $map = [ + 'ident_switch.form.imap.port' => 'imap.port', + 'ident_switch.form.imap.delimiter' => 'imap.delimiter', + 'ident_switch.form.imap.username' => 'imap.user', + 'ident_switch.form.smtp.port' => 'smtp.port', + 'ident_switch.form.sieve.port' => 'sieve.port', + ]; + + foreach ($map as $recordKey => $dataKey) { + if (empty($data[$dataKey]) && isset($record[$recordKey])) { + $data[$dataKey] = $record[$recordKey]; + } + } + + // Compose hosts with security scheme (preconfig stores them separately) + foreach (['imap', 'smtp', 'sieve'] as $proto) { + $hostKey = "ident_switch.form.{$proto}.host"; + $secKey = "ident_switch.form.{$proto}.security"; + if (empty($data["{$proto}.host"]) && !empty($record[$hostKey])) { + $security = $record[$secKey] ?? ''; + $data["{$proto}.host"] = self::compose_host_scheme($record[$hostKey], $security); + } + } + } + + /** + * Resolve IMAP password for connection testing. + * + * If the password is the sentinel (unchanged), decrypt from DB. + * Otherwise return the raw password as-is. + * + * @param rcmail $rc Roundcube instance. + * @param integer $iid Identity ID. + * @param string|null $pass Raw password from POST. + * @return string Decrypted or raw password for testing. + */ + private function resolve_password_for_test(rcmail $rc, int $iid, ?string $pass): string + { + if ($pass !== self::PASSWORD_SENTINEL) { + return $pass ?? ''; + } + + $sql = 'SELECT password FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $iid, $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if (!$r || empty($r['password'])) { + return ''; + } + + $decrypted = $rc->decrypt($r['password']); + return ($decrypted !== false) ? $decrypted : ''; + } + + /** + * Validate all plugin form field values from POST data. + * + * Checks length constraints, port ranges, and parses flags. + * Also retrieves and includes the password. + * + * @return array Validated data with field values and optional 'err' key on failure. + */ + public function validate(): array + { + $retVal = []; + + $retVal['label'] = self::get_field_value('common', 'label'); + if (strlen($retVal['label'] ?? '') > 32) { + $retVal['err'] = 'label.long'; + return $retVal; + } + + // Validate and compose IMAP host with security scheme + $imapBareHost = self::get_field_value('imap', 'host'); + $retVal['imap.host'] = $imapBareHost; + $imapSecurity = self::get_field_value('imap', 'security') ?? ''; + $retVal['imap.host'] = self::compose_host_scheme($retVal['imap.host'], $imapSecurity); + if (strlen($retVal['imap.host'] ?? '') > 64) { + $retVal['err'] = 'host.long'; + return $retVal; + } + + $retVal['imap.port'] = self::get_field_value('imap', 'port'); + if ($retVal['imap.port'] && !ctype_digit($retVal['imap.port'])) { + $retVal['err'] = 'port.num'; + return $retVal; + } + if ($retVal['imap.port'] && ($retVal['imap.port'] <= 0 || $retVal['imap.port'] > 65535)) { + $retVal['err'] = 'port.range'; + return $retVal; + } + + $delimMode = self::get_field_value('imap', 'delimiter_mode'); + if ($delimMode === 'manual') { + $retVal['imap.delimiter'] = self::get_field_value('imap', 'delimiter'); + if (strlen($retVal['imap.delimiter'] ?? '') > 1) { + $retVal['err'] = 'delim.long'; + return $retVal; + } + } else { + $retVal['imap.delimiter'] = null; + } + + $retVal['imap.user'] = self::get_field_value('imap', 'username'); + if (strlen($retVal['imap.user'] ?? '') > 64) { + $retVal['err'] = 'user.long'; + return $retVal; + } + + // Validate and compose SMTP host with security scheme (fallback to IMAP host) + $retVal['smtp.host'] = self::get_field_value('smtp', 'host') ?: $imapBareHost; + $smtpSecurity = self::get_field_value('smtp', 'security') ?? ''; + $retVal['smtp.host'] = self::compose_host_scheme($retVal['smtp.host'], $smtpSecurity); + if (strlen($retVal['smtp.host'] ?? '') > 64) { + $retVal['err'] = 'host.long'; + return $retVal; + } + + $retVal['smtp.port'] = self::get_field_value('smtp', 'port'); + if ($retVal['smtp.port'] && !ctype_digit($retVal['smtp.port'])) { + $retVal['err'] = 'port.num'; + return $retVal; + } + if ($retVal['smtp.port'] && ($retVal['smtp.port'] <= 0 || $retVal['smtp.port'] > 65535)) { + $retVal['err'] = 'port.range'; + return $retVal; + } + + $retVal['smtp.auth'] = self::get_field_value('smtp', 'auth') ?? (string)ident_switch::SMTP_AUTH_IMAP; + if (!ctype_digit($retVal['smtp.auth'])) { + $retVal['err'] = 'auth.num'; + return $retVal; + } + + // Custom SMTP credentials + if ((int)$retVal['smtp.auth'] === ident_switch::SMTP_AUTH_CUSTOM) { + $retVal['smtp.user'] = self::get_field_value('smtp', 'username'); + if (strlen($retVal['smtp.user'] ?? '') > 64) { + $retVal['err'] = 'user.long'; + return $retVal; + } + $retVal['smtp.pass'] = self::get_field_value('smtp', 'password', false, true); + } else { + $retVal['smtp.user'] = null; + $retVal['smtp.pass'] = null; + } + + // Validate and compose Sieve host with security scheme (fallback to IMAP host) + $retVal['sieve.host'] = self::get_field_value('sieve', 'host') ?: $imapBareHost; + $sieveSecurity = self::get_field_value('sieve', 'security') ?? ''; + $retVal['sieve.host'] = self::compose_host_scheme($retVal['sieve.host'], $sieveSecurity); + if (strlen($retVal['sieve.host'] ?? '') > 64) { + $retVal['err'] = 'host.long'; + return $retVal; + } + + $retVal['sieve.port'] = self::get_field_value('sieve', 'port'); + if ($retVal['sieve.port'] && !ctype_digit($retVal['sieve.port'])) { + $retVal['err'] = 'port.num'; + return $retVal; + } + if ($retVal['sieve.port'] && ($retVal['sieve.port'] <= 0 || $retVal['sieve.port'] > 65535)) { + $retVal['err'] = 'port.range'; + return $retVal; + } + + $retVal['sieve.auth'] = self::get_field_value('sieve', 'auth') ?? (string)ident_switch::SIEVE_AUTH_IMAP; + if (!ctype_digit($retVal['sieve.auth'])) { + $retVal['err'] = 'auth.num'; + return $retVal; + } + + // Custom Sieve credentials + if ((int)$retVal['sieve.auth'] === ident_switch::SIEVE_AUTH_CUSTOM) { + $retVal['sieve.user'] = self::get_field_value('sieve', 'username'); + if (strlen($retVal['sieve.user'] ?? '') > 64) { + $retVal['err'] = 'user.long'; + return $retVal; + } + $retVal['sieve.pass'] = self::get_field_value('sieve', 'password', false, true); + } else { + $retVal['sieve.user'] = null; + $retVal['sieve.pass'] = null; + } + + // Notification settings + $retVal['notify.check'] = self::get_field_value('notify', 'check', false) ? 1 : 0; + + $notifyBasic = self::get_field_value('notify', 'basic'); + $retVal['notify.basic'] = ($notifyBasic !== null && $notifyBasic !== '') ? (int)$notifyBasic : null; + + $notifySound = self::get_field_value('notify', 'sound'); + $retVal['notify.sound'] = ($notifySound !== null && $notifySound !== '') ? (int)$notifySound : null; + + $notifyDesktop = self::get_field_value('notify', 'desktop'); + $retVal['notify.desktop'] = ($notifyDesktop !== null && $notifyDesktop !== '') ? (int)$notifyDesktop : null; + + // Get also password + $retVal['imap.pass'] = self::get_field_value('imap', 'password', false, true); + + // Flags: only enabled, security is now in host field scheme + $retVal['flags'] = ident_switch::DB_ENABLED; + + return $retVal; + } + + /** + * Retrieve a form field value from POST input. + * + * @param string $section Form section name (common, imap, smtp). + * @param string $field Field name within the section. + * @param boolean $trim Whether to trim and nullify empty values. + * @param boolean $html Whether to allow HTML in the value. + * @return string|null The field value, or null if empty and trimmed. + */ + public static function get_field_value(string $section, string $field, bool $trim = true, bool $html = false): ?string + { + $retVal = rcube_utils::get_input_value( + "_ident_switch_form_{$section}_{$field}", + rcube_utils::INPUT_POST, + $html + ); + if (!$trim) { + return $retVal; + } + + return ident_switch::ntrim($retVal); + } + + /** + * Persist plugin field values to the database (insert or update). + * + * Handles password encryption and determines whether to create a new + * record or update an existing one. + * + * @param rcmail $rc Roundcube instance for DB access and encryption. + * @param array $data Validated field data including 'id' (identity_id). + * @return boolean True if a query was executed, false otherwise. + */ + public function save(rcmail $rc, array $data): bool + { + $sql = 'SELECT id, password, smtp_password, sieve_password FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $data['id'], $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if ($r) { + // Record already exists, will update it + $sql = 'UPDATE ' . + $rc->db->table_name(ident_switch::TABLE) . + ' SET flags = ?, parent_id = ?, label = ?, imap_host = ?, imap_port = ?, imap_delimiter = ?, username = ?, password = ?,' . + ' smtp_host = ?, smtp_port = ?, smtp_auth = ?, smtp_username = ?, smtp_password = ?,' . + ' sieve_host = ?, sieve_port = ?, sieve_auth = ?, sieve_username = ?, sieve_password = ?,' . + ' notify_check = ?, notify_basic = ?, notify_sound = ?, notify_desktop = ?,' . + ' user_id = ?, iid = ?' . + ' WHERE id = ?'; + } elseif ($data['flags'] & ident_switch::DB_ENABLED) { + // No record exists, create new one + $sql = 'INSERT INTO ' . + $rc->db->table_name(ident_switch::TABLE) . + '(flags, parent_id, label, imap_host, imap_port, imap_delimiter, username, password,' . + ' smtp_host, smtp_port, smtp_auth, smtp_username, smtp_password,' . + ' sieve_host, sieve_port, sieve_auth, sieve_username, sieve_password,' . + ' notify_check, notify_basic, notify_sound, notify_desktop,' . + ' user_id, iid) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'; + } else { + return false; + } + + // Handle IMAP password: sentinel means unchanged, empty means clear, else encrypt + if ($data['imap.pass'] === self::PASSWORD_SENTINEL) { + $data['imap.pass'] = $r['password'] ?? null; + } else { + $data['imap.pass'] = $data['imap.pass'] ? $rc->encrypt($data['imap.pass']) : null; + } + + // Handle SMTP password + if ($data['smtp.pass'] === self::PASSWORD_SENTINEL) { + $data['smtp.pass'] = $r['smtp_password'] ?? null; + } else { + $data['smtp.pass'] = $data['smtp.pass'] ? $rc->encrypt($data['smtp.pass']) : null; + } + + // Handle Sieve password + if ($data['sieve.pass'] === self::PASSWORD_SENTINEL) { + $data['sieve.pass'] = $r['sieve_password'] ?? null; + } else { + $data['sieve.pass'] = $data['sieve.pass'] ? $rc->encrypt($data['sieve.pass']) : null; + } + + $rc->db->query( + $sql, + $data['flags'], + null, // parent_id: NULL for separate accounts + $data['label'], + $data['imap.host'], + $data['imap.port'], + $data['imap.delimiter'], + $data['imap.user'], + $data['imap.pass'], + $data['smtp.host'], + $data['smtp.port'], + $data['smtp.auth'], + $data['smtp.user'], + $data['smtp.pass'], + $data['sieve.host'], + $data['sieve.port'], + $data['sieve.auth'], + $data['sieve.user'], + $data['sieve.pass'], + $data['notify.check'] ?? 1, + $data['notify.basic'] ?? null, + $data['notify.sound'] ?? null, + $data['notify.desktop'] ?? null, + $rc->user->ID, + $data['id'], + $r['id'] ?? null + ); + + return true; + } + + /** + * Save an alias link between an identity and a parent account. + * + * Creates or updates an ident_switch record with parent_id set and + * all server fields cleared (alias uses parent's config). + * + * @param integer $iid Identity ID of the alias. + * @param integer $parentId Ident_switch.id of the parent account. + */ + private function save_alias(int $iid, int $parentId): void + { + $rc = rcmail::get_instance(); + + // Validate that parent_id exists and belongs to this user + $sql = 'SELECT id FROM ' . $rc->db->table_name(ident_switch::TABLE) + . ' WHERE id = ? AND user_id = ? AND parent_id IS NULL'; + $q = $rc->db->query($sql, $parentId, $rc->user->ID); + if (!$rc->db->fetch_assoc($q)) { + ident_switch::write_log("Alias save: parent account with id={$parentId} not found for user."); + return; + } + + $label = self::get_field_value('common', 'label'); + + // Check if record already exists + $sql = 'SELECT id FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $iid, $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + + if ($r) { + $sql = 'UPDATE ' . $rc->db->table_name(ident_switch::TABLE) + . ' SET flags = ?, parent_id = ?, label = ?,' + . ' imap_host = NULL, imap_port = NULL, imap_delimiter = NULL,' + . ' username = NULL, password = NULL,' + . ' smtp_host = NULL, smtp_port = NULL, smtp_auth = 1, smtp_username = NULL, smtp_password = NULL,' + . ' sieve_host = NULL, sieve_port = NULL, sieve_auth = 1, sieve_username = NULL, sieve_password = NULL,' + . ' notify_check = 0, notify_basic = NULL, notify_sound = NULL, notify_desktop = NULL' + . ' WHERE id = ?'; + $rc->db->query($sql, ident_switch::DB_ENABLED, $parentId, $label, $r['id']); + } else { + $sql = 'INSERT INTO ' . $rc->db->table_name(ident_switch::TABLE) + . ' (flags, parent_id, label, user_id, iid) VALUES (?, ?, ?, ?, ?)'; + $rc->db->query($sql, ident_switch::DB_ENABLED, $parentId, $label, $rc->user->ID, $iid); + } + + ident_switch::write_log("Saved alias link: identity {$iid} → parent account {$parentId}."); + } + + /** + * Disable the ident_switch flag for a given identity. + * + * @param integer $iid Identity ID to disable. + */ + public function disable(int $iid): void + { + $rc = rcmail::get_instance(); + + $sql = 'UPDATE ' . $rc->db->table_name(ident_switch::TABLE) . ' SET flags = flags & ? WHERE iid = ? AND user_id = ?'; + $rc->db->query($sql, ~ident_switch::DB_ENABLED, $iid, $rc->user->ID); + } } diff --git a/lib/IdentSwitchPreconfig.php b/lib/IdentSwitchPreconfig.php index 75f37ea..277568b 100644 --- a/lib/IdentSwitchPreconfig.php +++ b/lib/IdentSwitchPreconfig.php @@ -1,4 +1,5 @@ plugin = $plugin; - } + /** + * Constructor. + * + * @param ident_switch $plugin Parent plugin instance. + */ + public function __construct(ident_switch $plugin) + { + $this->plugin = $plugin; + } - /** - * Load preconfigured settings for a domain from config. - * - * @param string $email Email address to extract domain from. - * @return array|false Preconfig array for the domain, or false if not found. - */ - public function get(string $email): array|false - { - $dom = substr(strstr($email, '@'), 1); - if (!$dom) { - return false; - } + /** + * Load preconfigured settings for a domain from config. + * + * @param string $email Email address to extract domain from. + * @return array|false Preconfig array for the domain, or false if not found. + */ + public function get(string $email): array|false + { + $dom = substr(strstr($email, '@'), 1); + if (!$dom) { + return false; + } - $this->plugin->load_config(); + $this->plugin->load_config(); - $cfg = rcmail::get_instance()->config->get('ident_switch.preconfig', []); - $cfg = $cfg[$dom] ?? $cfg['*'] ?? null; + $cfg = rcmail::get_instance()->config->get('ident_switch.preconfig', []); + $cfg = $cfg[$dom] ?? $cfg['*'] ?? null; - if ($cfg) { - if (empty($cfg['imap_host']) && empty($cfg['host'])) { - return false; - } - } - return $cfg ?: false; - } + if ($cfg) { + if (empty($cfg['imap_host']) && empty($cfg['host'])) { + return false; + } + } + return $cfg ?: false; + } - /** - * Apply preconfigured settings to an identity form record. - * - * Parses IMAP, SMTP, and Sieve host URLs to extract scheme, host, - * and port, then sets the username and delimiter based on config values. - * - * @param array $record Identity record to modify (passed by reference). - * @return bool True if the preconfig is readonly, false otherwise. - */ - public function apply(array &$record): bool - { - $email = $record['email'] ?? ''; - if (empty($email)) { - return false; - } + /** + * Apply preconfigured settings to an identity form record. + * + * Parses IMAP, SMTP, and Sieve host URLs to extract scheme, host, + * and port, then sets the username and delimiter based on config values. + * + * @param array $record Identity record to modify (passed by reference). + * @return boolean True if the preconfig is readonly, false otherwise. + */ + public function apply(array &$record): bool + { + $email = $record['email'] ?? ''; + if (empty($email)) { + return false; + } - $cfg = $this->get($email); - if (is_array($cfg)) { - ident_switch::write_log("Applying predefined configuration for '{$email}'."); + $cfg = $this->get($email); + if (is_array($cfg)) { + ident_switch::write_log("Applying predefined configuration for '{$email}'."); - // Parse each protocol URL into host, security, and port - $protocols = [ - 'imap' => $cfg['imap_host'] ?? $cfg['host'] ?? '', - 'smtp' => $cfg['smtp_host'] ?? $cfg['host'] ?? '', - 'sieve' => $cfg['sieve_host'] ?? '', - ]; + // Parse each protocol URL into host, security, and port + $protocols = [ + 'imap' => $cfg['imap_host'] ?? $cfg['host'] ?? '', + 'smtp' => $cfg['smtp_host'] ?? $cfg['host'] ?? '', + 'sieve' => $cfg['sieve_host'] ?? '', + ]; - foreach ($protocols as $proto => $url) { - if (empty($url)) { - continue; - } - $urlArr = parse_url($url); - $host = !empty($urlArr['host']) ? rcube::Q($urlArr['host'], 'url') : ''; - $scheme = strtolower($urlArr['scheme'] ?? ''); + foreach ($protocols as $proto => $url) { + if (empty($url)) { + continue; + } + $urlArr = parse_url($url); + $host = !empty($urlArr['host']) ? rcube::Q($urlArr['host'], 'url') : ''; + $scheme = strtolower($urlArr['scheme'] ?? ''); - $record["ident_switch.form.{$proto}.host"] = $host; - $record["ident_switch.form.{$proto}.security"] = in_array($scheme, ['ssl', 'tls']) ? $scheme : ''; - $record["ident_switch.form.{$proto}.port"] = !empty($urlArr['port']) ? intval($urlArr['port']) : ''; - } + $record["ident_switch.form.{$proto}.host"] = $host; + $record["ident_switch.form.{$proto}.security"] = in_array($scheme, ['ssl', 'tls']) ? $scheme : ''; + $record["ident_switch.form.{$proto}.port"] = !empty($urlArr['port']) ? intval($urlArr['port']) : ''; + } - $loginSet = false; - if (!empty($cfg['user'])) { - match (strtoupper($cfg['user'])) { - 'EMAIL' => ($record['ident_switch.form.imap.username'] = $email) && ($loginSet = true), - 'MBOX' => ($record['ident_switch.form.imap.username'] = strstr($email, '@', true)) && ($loginSet = true), - default => null, - }; - } + $loginSet = false; + if (!empty($cfg['user'])) { + match (strtoupper($cfg['user'])) { + 'EMAIL' => ($record['ident_switch.form.imap.username'] = $email) && ($loginSet = true), + 'MBOX' => ($record['ident_switch.form.imap.username'] = strstr($email, '@', true)) && ($loginSet = true), + default => null, + }; + } - if (!empty($cfg['readonly'])) { - $record['ident_switch.form.common.readonly'] = $loginSet ? 2 : 1; - } + if (!empty($cfg['readonly'])) { + $record['ident_switch.form.common.readonly'] = $loginSet ? 2 : 1; + } - // IMAP folder hierarchy delimiter (null or absent = auto-detect) - if (isset($cfg['delimiter'])) { - $record['ident_switch.form.imap.delimiter'] = $cfg['delimiter']; - } + // IMAP folder hierarchy delimiter (null or absent = auto-detect) + if (isset($cfg['delimiter'])) { + $record['ident_switch.form.imap.delimiter'] = $cfg['delimiter']; + } - // Notification defaults from preconfig - if (isset($cfg['notify_check'])) { - $record['ident_switch.form.notify.check'] = $cfg['notify_check'] ? 1 : 0; - } - foreach (['notify_basic', 'notify_sound', 'notify_desktop'] as $key) { - if (isset($cfg[$key])) { - $formKey = 'ident_switch.form.notify.' . substr($key, 7); - $record[$formKey] = $cfg[$key] === null ? '' : ($cfg[$key] ? '1' : '0'); - } - } + // Notification defaults from preconfig + if (isset($cfg['notify_check'])) { + $record['ident_switch.form.notify.check'] = $cfg['notify_check'] ? 1 : 0; + } + foreach (['notify_basic', 'notify_sound', 'notify_desktop'] as $key) { + if (isset($cfg[$key])) { + $formKey = 'ident_switch.form.notify.' . substr($key, 7); + $record[$formKey] = $cfg[$key] === null ? '' : ($cfg[$key] ? '1' : '0'); + } + } - return (bool)($cfg['readonly'] ?? false); - } + return (bool)($cfg['readonly'] ?? false); + } - return false; - } + return false; + } } diff --git a/lib/IdentSwitchSwitcher.php b/lib/IdentSwitchSwitcher.php index 362263e..0dca984 100644 --- a/lib/IdentSwitchSwitcher.php +++ b/lib/IdentSwitchSwitcher.php @@ -1,4 +1,5 @@ session->remove('folders'); - $rc->session->remove('unseen_count'); + $rc->session->remove('folders'); + $rc->session->remove('unseen_count'); - // Reset baseline for the target account so delta goes back to 0 - $this->reset_baseline($identId === -1 ? 0 : null, $rc, $identId); + // Reset baseline for the target account so delta goes back to 0 + $this->reset_baseline($identId === -1 ? 0 : null, $rc, $identId); - if ($identId === -1) { - // Switch to main account - ident_switch::write_log('Switching mailbox back to default.'); + if ($identId === -1) { + // Switch to main account + ident_switch::write_log('Switching mailbox back to default.'); - // Restore everything with STORAGE*my_postfix - foreach ($_SESSION as $k => $v) { - if (str_starts_with(strtolower($k), 'storage') && str_ends_with($k, ident_switch::MY_POSTFIX)) { - $realKey = substr($k, 0, -$my_postfix_len); - $_SESSION[$realKey] = $_SESSION[$k]; - $rc->session->remove($k); - } - } + // Restore everything with STORAGE*my_postfix + foreach ($_SESSION as $k => $v) { + if (str_starts_with(strtolower($k), 'storage') && str_ends_with($k, ident_switch::MY_POSTFIX)) { + $realKey = substr($k, 0, -$my_postfix_len); + $_SESSION[$realKey] = $_SESSION[$k]; + $rc->session->remove($k); + } + } - $_SESSION['imap_delimiter'] = $_SESSION['imap_delimiter' . ident_switch::MY_POSTFIX] ?? null; - $_SESSION['username'] = $rc->user->data['username']; - $_SESSION['password'] = $_SESSION['password' . ident_switch::MY_POSTFIX]; - $_SESSION['iid' . ident_switch::MY_POSTFIX] = -1; + $_SESSION['imap_delimiter'] = $_SESSION['imap_delimiter' . ident_switch::MY_POSTFIX] ?? null; + $_SESSION['username'] = $rc->user->data['username']; + $_SESSION['password'] = $_SESSION['password' . ident_switch::MY_POSTFIX]; + $_SESSION['iid' . ident_switch::MY_POSTFIX] = -1; - foreach (rcube_storage::$folder_types as $type) { - $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; - if (isset($_SESSION[$otherKey])) { - $rc->session->remove($otherKey); - } - } - } else { - $sql = 'SELECT imap_host, flags, imap_port, imap_delimiter, drafts_mbox, sent_mbox, junk_mbox, trash_mbox, username, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; - $q = $rc->db->query($sql, $identId, $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if (is_array($r)) { - $r['username'] = ident_switch::resolve_username((int)$r['iid'], $r['username']); + foreach (rcube_storage::$folder_types as $type) { + $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; + if (isset($_SESSION[$otherKey])) { + $rc->session->remove($otherKey); + } + } + } else { + $sql = 'SELECT imap_host, flags, imap_port, imap_delimiter, drafts_mbox, sent_mbox, junk_mbox, trash_mbox, username, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; + $q = $rc->db->query($sql, $identId, $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if (is_array($r)) { + $r['username'] = ident_switch::resolve_username((int)$r['iid'], $r['username']); - ident_switch::write_log("Switching mailbox to one for identity with ID = {$r['iid']} (username = '{$r['username']}')."); + ident_switch::write_log("Switching mailbox to one for identity with ID = {$r['iid']} (username = '{$r['username']}')."); - if ($_SESSION['username'] === $rc->user->data['username']) { - // If we are in default account now - save values - foreach ($_SESSION as $k => $v) { - if (str_starts_with(strtolower($k), 'storage') && !str_ends_with($k, ident_switch::MY_POSTFIX)) { - if (!isset($_SESSION[$k . ident_switch::MY_POSTFIX])) { - $_SESSION[$k . ident_switch::MY_POSTFIX] = $_SESSION[$k]; - } - $rc->session->remove($k); - } - } + if ($_SESSION['username'] === $rc->user->data['username']) { + // If we are in default account now - save values + foreach ($_SESSION as $k => $v) { + if (str_starts_with(strtolower($k), 'storage') && !str_ends_with($k, ident_switch::MY_POSTFIX)) { + if (!isset($_SESSION[$k . ident_switch::MY_POSTFIX])) { + $_SESSION[$k . ident_switch::MY_POSTFIX] = $_SESSION[$k]; + } + $rc->session->remove($k); + } + } - foreach (['password', 'imap_delimiter'] as $k) { - if (!isset($_SESSION[$k . ident_switch::MY_POSTFIX])) { - $_SESSION[$k . ident_switch::MY_POSTFIX] = $_SESSION[$k]; - } - $rc->session->remove($k); - } - } + foreach (['password', 'imap_delimiter'] as $k) { + if (!isset($_SESSION[$k . ident_switch::MY_POSTFIX])) { + $_SESSION[$k . ident_switch::MY_POSTFIX] = $_SESSION[$k]; + } + $rc->session->remove($k); + } + } - $parsed = ident_switch::parse_host_scheme($r['imap_host'] ?: 'localhost'); - $host = $parsed['host']; - $ssl = $parsed['scheme'] ?: null; + $parsed = ident_switch::parse_host_scheme($r['imap_host'] ?: 'localhost'); + $host = $parsed['host']; + $ssl = $parsed['scheme'] ?: null; - if (!$ssl && ($r['flags'] & ident_switch::DB_SECURE_IMAP_TLS)) { - $ssl = 'tls'; // Backward compat: old records without scheme in host - } + if (!$ssl && ($r['flags'] & ident_switch::DB_SECURE_IMAP_TLS)) { + $ssl = 'tls'; // Backward compat: old records without scheme in host + } - $def_port = ($ssl === 'ssl') ? 993 : 143; - $port = $r['imap_port'] ?: $def_port; + $def_port = ($ssl === 'ssl') ? 993 : 143; + $port = $r['imap_port'] ?: $def_port; - $delimiter = $r['imap_delimiter'] ?: null; + $delimiter = $r['imap_delimiter'] ?: null; - $_SESSION['storage_host'] = $host; - $_SESSION['storage_ssl'] = $ssl; - $_SESSION['storage_port'] = $port; - $_SESSION['imap_delimiter'] = $delimiter; - $_SESSION['username'] = $r['username']; - $_SESSION['password'] = $r['password']; - $_SESSION['iid' . ident_switch::MY_POSTFIX] = $r['iid']; + $_SESSION['storage_host'] = $host; + $_SESSION['storage_ssl'] = $ssl; + $_SESSION['storage_port'] = $port; + $_SESSION['imap_delimiter'] = $delimiter; + $_SESSION['username'] = $r['username']; + $_SESSION['password'] = $r['password']; + $_SESSION['iid' . ident_switch::MY_POSTFIX] = $r['iid']; - foreach (rcube_storage::$folder_types as $type) { - if (!empty($r[$type . '_mbox'])) { - $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; - $_SESSION[$otherKey] = $r[$type . '_mbox']; - } - } - } else { - ident_switch::write_log("Requested remote mailbox with ID = {$identId} not found."); - return; - } - } + foreach (rcube_storage::$folder_types as $type) { + if (!empty($r[$type . '_mbox'])) { + $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; + $_SESSION[$otherKey] = $r[$type . '_mbox']; + } + } + } else { + ident_switch::write_log("Requested remote mailbox with ID = {$identId} not found."); + return; + } + } - $rc->output->redirect([ - '_task' => 'mail', - '_mbox' => 'INBOX', - ]); - } + $rc->output->redirect([ + '_task' => 'mail', + '_mbox' => 'INBOX', + ]); + } - /** - * Handle smtp_connect hook: configure SMTP settings for the active account. - * - * Loads SMTP host, port, credentials, and TLS settings from the database - * for the currently selected identity. - * - * @param array $args Hook arguments containing SMTP connection parameters. - * @return array Modified hook arguments with updated SMTP settings. - */ - public function configure_smtp(array $args): array - { - $iid = $_SESSION['iid' . ident_switch::MY_POSTFIX] ?? null; - if (!is_numeric($iid) || (int)$iid === -1) { - ident_switch::debug_log('SMTP: no active switch, resolving from _from header'); - $requestFrom = rcube_utils::get_input_value('_from', rcube_utils::INPUT_POST); - if (empty($requestFrom)) { - ident_switch::debug_log('SMTP: no _from parameter, using default config'); - return $args; - } + /** + * Handle smtp_connect hook: configure SMTP settings for the active account. + * + * Loads SMTP host, port, credentials, and TLS settings from the database + * for the currently selected identity. + * + * @param array $args Hook arguments containing SMTP connection parameters. + * @return array Modified hook arguments with updated SMTP settings. + */ + public function configure_smtp(array $args): array + { + $iid = $_SESSION['iid' . ident_switch::MY_POSTFIX] ?? null; + if (!is_numeric($iid) || (int)$iid === -1) { + ident_switch::debug_log('SMTP: no active switch, resolving from _from header'); + $requestFrom = rcube_utils::get_input_value('_from', rcube_utils::INPUT_POST); + if (empty($requestFrom)) { + ident_switch::debug_log('SMTP: no _from parameter, using default config'); + return $args; + } - $iid = intval($requestFrom); - if ($iid === 0) { - ident_switch::debug_log('SMTP: _from is not an integer, using default config'); - return $args; - } - } + $iid = intval($requestFrom); + if ($iid === 0) { + ident_switch::debug_log('SMTP: _from is not an integer, using default config'); + return $args; + } + } - $rc = rcmail::get_instance(); + $rc = rcmail::get_instance(); - $sql = 'SELECT parent_id, smtp_host, smtp_port, username, smtp_auth, smtp_username, smtp_password, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $iid, $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if (is_array($r)) { - // If this is an alias, follow parent_id to get the parent's SMTP config - if (!empty($r['parent_id'])) { - ident_switch::debug_log("SMTP: identity {$iid} is alias, following parent_id={$r['parent_id']}"); - $sql = 'SELECT smtp_host, smtp_port, username, smtp_auth, smtp_username, smtp_password, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; - $q = $rc->db->query($sql, $r['parent_id'], $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if (!is_array($r)) { - ident_switch::debug_log("SMTP: parent account not found, using default config"); - return $args; - } - $iid = $r['iid']; - } + $sql = 'SELECT parent_id, smtp_host, smtp_port, username, smtp_auth, smtp_username, smtp_password, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $iid, $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if (is_array($r)) { + // If this is an alias, follow parent_id to get the parent's SMTP config + if (!empty($r['parent_id'])) { + ident_switch::debug_log("SMTP: identity {$iid} is alias, following parent_id={$r['parent_id']}"); + $sql = 'SELECT smtp_host, smtp_port, username, smtp_auth, smtp_username, smtp_password, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; + $q = $rc->db->query($sql, $r['parent_id'], $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if (!is_array($r)) { + ident_switch::debug_log("SMTP: parent account not found, using default config"); + return $args; + } + $iid = $r['iid']; + } - $r['username'] = ident_switch::resolve_username($iid, $r['username']); + $r['username'] = ident_switch::resolve_username($iid, $r['username']); - $authMode = (int)$r['smtp_auth']; - if ($authMode === ident_switch::SMTP_AUTH_CUSTOM) { - $args['smtp_user'] = $r['smtp_username'] ?: ''; - $args['smtp_pass'] = $r['smtp_password'] ? ($rc->decrypt($r['smtp_password']) ?: '') : ''; - } elseif ($authMode === ident_switch::SMTP_AUTH_IMAP) { - $args['smtp_user'] = $r['username']; - $args['smtp_pass'] = $rc->decrypt($r['password']) ?: ''; - } else { - $args['smtp_user'] = ''; - $args['smtp_pass'] = ''; - } + $authMode = (int)$r['smtp_auth']; + if ($authMode === ident_switch::SMTP_AUTH_CUSTOM) { + $args['smtp_user'] = $r['smtp_username'] ?: ''; + $args['smtp_pass'] = $r['smtp_password'] ? ($rc->decrypt($r['smtp_password']) ?: '') : ''; + } elseif ($authMode === ident_switch::SMTP_AUTH_IMAP) { + $args['smtp_user'] = $r['username']; + $args['smtp_pass'] = $rc->decrypt($r['password']) ?: ''; + } else { + $args['smtp_user'] = ''; + $args['smtp_pass'] = ''; + } - // Host already contains scheme (ssl:// or tls://) from form - $smtpHost = $r['smtp_host'] ?: 'localhost'; - $smtpPort = $r['smtp_port'] ?: 587; - $args['smtp_host'] = $smtpHost . ':' . $smtpPort; + // Host already contains scheme (ssl:// or tls://) from form + $smtpHost = $r['smtp_host'] ?: 'localhost'; + $smtpPort = $r['smtp_port'] ?: 587; + $args['smtp_host'] = $smtpHost . ':' . $smtpPort; - $authLabel = match ($authMode) { - ident_switch::SMTP_AUTH_IMAP => 'imap', - ident_switch::SMTP_AUTH_NONE => 'none', - ident_switch::SMTP_AUTH_CUSTOM => 'custom', - default => "unknown({$authMode})", - }; - ident_switch::debug_log("SMTP: iid={$iid}, host={$args['smtp_host']}, user={$args['smtp_user']}, auth={$authLabel}"); - } + $authLabel = match ($authMode) { + ident_switch::SMTP_AUTH_IMAP => 'imap', + ident_switch::SMTP_AUTH_NONE => 'none', + ident_switch::SMTP_AUTH_CUSTOM => 'custom', + default => "unknown({$authMode})", + }; + ident_switch::debug_log("SMTP: iid={$iid}, host={$args['smtp_host']}, user={$args['smtp_user']}, auth={$authLabel}"); + } - return $args; - } + return $args; + } - /** - * Handle managesieve_connect hook: configure Sieve settings for the active account. - * - * Loads Sieve host, port, and credentials from the database - * for the currently selected identity. - * - * @param array $args Hook arguments containing Sieve connection parameters. - * @return array Modified hook arguments with updated Sieve settings. - */ - public function configure_managesieve(array $args): array - { - $iid = $_SESSION['iid' . ident_switch::MY_POSTFIX] ?? null; - if (!is_numeric($iid) || (int)$iid === -1) { - return $args; - } + /** + * Handle managesieve_connect hook: configure Sieve settings for the active account. + * + * Loads Sieve host, port, and credentials from the database + * for the currently selected identity. + * + * @param array $args Hook arguments containing Sieve connection parameters. + * @return array Modified hook arguments with updated Sieve settings. + */ + public function configure_managesieve(array $args): array + { + $iid = $_SESSION['iid' . ident_switch::MY_POSTFIX] ?? null; + if (!is_numeric($iid) || (int)$iid === -1) { + return $args; + } - $rc = rcmail::get_instance(); + $rc = rcmail::get_instance(); - $sql = 'SELECT parent_id, sieve_host, sieve_port, sieve_auth, sieve_username, sieve_password, username, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $iid, $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if (is_array($r)) { - // If this is an alias, follow parent_id to get the parent's Sieve config - if (!empty($r['parent_id'])) { - ident_switch::debug_log("Sieve: identity {$iid} is alias, following parent_id={$r['parent_id']}"); - $sql = 'SELECT sieve_host, sieve_port, sieve_auth, sieve_username, sieve_password, username, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; - $q = $rc->db->query($sql, $r['parent_id'], $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if (!is_array($r)) { - ident_switch::debug_log("Sieve: parent account not found, using default config"); - return $args; - } - $iid = $r['iid']; - } + $sql = 'SELECT parent_id, sieve_host, sieve_port, sieve_auth, sieve_username, sieve_password, username, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $iid, $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if (is_array($r)) { + // If this is an alias, follow parent_id to get the parent's Sieve config + if (!empty($r['parent_id'])) { + ident_switch::debug_log("Sieve: identity {$iid} is alias, following parent_id={$r['parent_id']}"); + $sql = 'SELECT sieve_host, sieve_port, sieve_auth, sieve_username, sieve_password, username, password, iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; + $q = $rc->db->query($sql, $r['parent_id'], $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if (!is_array($r)) { + ident_switch::debug_log("Sieve: parent account not found, using default config"); + return $args; + } + $iid = $r['iid']; + } - if (empty($r['sieve_host'])) { - return $args; - } + if (empty($r['sieve_host'])) { + return $args; + } - $r['username'] = ident_switch::resolve_username($iid, $r['username']); + $r['username'] = ident_switch::resolve_username($iid, $r['username']); - $sieveHost = $r['sieve_host']; - $sievePort = $r['sieve_port'] ?: 4190; - $args['host'] = $sieveHost . ':' . $sievePort; + $sieveHost = $r['sieve_host']; + $sievePort = $r['sieve_port'] ?: 4190; + $args['host'] = $sieveHost . ':' . $sievePort; - $authMode = (int)$r['sieve_auth']; - if ($authMode === ident_switch::SIEVE_AUTH_CUSTOM) { - $args['user'] = $r['sieve_username'] ?: ''; - $args['password'] = $r['sieve_password'] ? ($rc->decrypt($r['sieve_password']) ?: '') : ''; - } elseif ($authMode === ident_switch::SIEVE_AUTH_IMAP) { - $args['user'] = $r['username']; - $args['password'] = $rc->decrypt($r['password']) ?: ''; - } else { - $args['user'] = ''; - $args['password'] = ''; - } + $authMode = (int)$r['sieve_auth']; + if ($authMode === ident_switch::SIEVE_AUTH_CUSTOM) { + $args['user'] = $r['sieve_username'] ?: ''; + $args['password'] = $r['sieve_password'] ? ($rc->decrypt($r['sieve_password']) ?: '') : ''; + } elseif ($authMode === ident_switch::SIEVE_AUTH_IMAP) { + $args['user'] = $r['username']; + $args['password'] = $rc->decrypt($r['password']) ?: ''; + } else { + $args['user'] = ''; + $args['password'] = ''; + } - ident_switch::debug_log("Sieve: iid={$iid}, host={$args['host']}, user={$args['user']}"); - } + ident_switch::debug_log("Sieve: iid={$iid}, host={$args['host']}, user={$args['user']}"); + } - return $args; - } + return $args; + } - /** - * Handle preferences_list hook: customize special folders form for remote accounts. - * - * When viewing folder preferences while impersonating, shows the remote account's - * special folder assignments instead of the default ones. - * - * @param array $args Hook arguments containing 'section' and 'blocks' with form data. - * @return array Modified hook arguments with updated folder selections. - */ - public function get_special_folders_form(array $args): array - { - $rc = rcmail::get_instance(); + /** + * Handle preferences_list hook: customize special folders form for remote accounts. + * + * When viewing folder preferences while impersonating, shows the remote account's + * special folder assignments instead of the default ones. + * + * @param array $args Hook arguments containing 'section' and 'blocks' with form data. + * @return array Modified hook arguments with updated folder selections. + */ + public function get_special_folders_form(array $args): array + { + $rc = rcmail::get_instance(); - if ($args['section'] === 'folders' - && strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0 - ) { - $no_override = array_flip((array)$rc->config->get('dont_override')); - $onchange = "if ($(this).val() == 'INBOX') $(this).val('')"; - $select = $rc->folder_selector([ - 'noselection' => '---', - 'realnames' => true, - 'maxlength' => 30, - 'folder_filter' => 'mail', - 'folder_rights' => 'w', - ]); + if ( + $args['section'] === 'folders' + && strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0 + ) { + $no_override = array_flip((array)$rc->config->get('dont_override')); + $onchange = "if ($(this).val() == 'INBOX') $(this).val('')"; + $select = $rc->folder_selector([ + 'noselection' => '---', + 'realnames' => true, + 'maxlength' => 30, + 'folder_filter' => 'mail', + 'folder_rights' => 'w', + ]); - $sql = 'SELECT label FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $_SESSION['iid' . ident_switch::MY_POSTFIX], $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - $args['blocks']['main']['name'] .= ' (' . ($r['label'] ? rcube::Q($rc->gettext('server')) . ': ' . rcube::Q($r['label']) : 'remote') . ')'; + $sql = 'SELECT label FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $_SESSION['iid' . ident_switch::MY_POSTFIX], $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + $args['blocks']['main']['name'] .= ' (' . ($r['label'] ? rcube::Q($rc->gettext('server')) . ': ' . rcube::Q($r['label']) : 'remote') . ')'; - foreach (rcube_storage::$folder_types as $type) { - if (isset($no_override[$type . '_mbox'])) { - continue; - } + foreach (rcube_storage::$folder_types as $type) { + if (isset($no_override[$type . '_mbox'])) { + continue; + } - $defaultKey = $type . '_mbox_default' . ident_switch::MY_POSTFIX; - $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; - $selected = $_SESSION[$otherKey] ?? $_SESSION[$defaultKey] ?? ''; - $attr = ['id' => '_' . $type . '_mbox', 'name' => '_' . $type . '_mbox', 'onchange' => $onchange]; - $args['blocks']['main']['options'][$type . '_mbox']['content'] = $select->show($selected, $attr); - } - } + $defaultKey = $type . '_mbox_default' . ident_switch::MY_POSTFIX; + $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; + $selected = $_SESSION[$otherKey] ?? $_SESSION[$defaultKey] ?? ''; + $attr = ['id' => '_' . $type . '_mbox', 'name' => '_' . $type . '_mbox', 'onchange' => $onchange]; + $args['blocks']['main']['options'][$type . '_mbox']['content'] = $select->show($selected, $attr); + } + } - return $args; - } + return $args; + } - /** - * Handle preferences_save hook: persist special folder assignments for remote accounts. - * - * Saves folder preferences to the plugin's database table instead of the default - * Roundcube preferences when impersonating a remote account. - * - * @param array $args Hook arguments containing 'section' and 'prefs' with folder data. - * @return array Modified hook arguments, with 'abort' set to prevent default save. - */ - public function save_special_folders(array $args): array - { - $rc = rcmail::get_instance(); + /** + * Handle preferences_save hook: persist special folder assignments for remote accounts. + * + * Saves folder preferences to the plugin's database table instead of the default + * Roundcube preferences when impersonating a remote account. + * + * @param array $args Hook arguments containing 'section' and 'prefs' with folder data. + * @return array Modified hook arguments, with 'abort' set to prevent default save. + */ + public function save_special_folders(array $args): array + { + $rc = rcmail::get_instance(); - if ($args['section'] === 'folders' - && strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0 - ) { - $sql = 'SELECT id FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; - $q = $rc->db->query($sql, $_SESSION['iid' . ident_switch::MY_POSTFIX], $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if ($r) { - $sql = 'UPDATE ' . - $rc->db->table_name(ident_switch::TABLE) . - ' SET drafts_mbox = ?, sent_mbox = ?, junk_mbox = ?, trash_mbox = ?' . - ' WHERE id = ?'; + if ( + $args['section'] === 'folders' + && strcasecmp($rc->user->data['username'], $_SESSION['username']) !== 0 + ) { + $sql = 'SELECT id FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE iid = ? AND user_id = ?'; + $q = $rc->db->query($sql, $_SESSION['iid' . ident_switch::MY_POSTFIX], $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if ($r) { + $sql = 'UPDATE ' . + $rc->db->table_name(ident_switch::TABLE) . + ' SET drafts_mbox = ?, sent_mbox = ?, junk_mbox = ?, trash_mbox = ?' . + ' WHERE id = ?'; - $rc->db->query( - $sql, - $args['prefs']['drafts_mbox'], - $args['prefs']['sent_mbox'], - $args['prefs']['junk_mbox'], - $args['prefs']['trash_mbox'], - $r['id'] - ); + $rc->db->query( + $sql, + $args['prefs']['drafts_mbox'], + $args['prefs']['sent_mbox'], + $args['prefs']['junk_mbox'], + $args['prefs']['trash_mbox'], + $r['id'] + ); - // Abort to prevent RC from saving prefs to default storage - $args['abort'] = true; - $args['result'] = true; + // Abort to prevent RC from saving prefs to default storage + $args['abort'] = true; + $args['result'] = true; - foreach (rcube_storage::$folder_types as $type) { - if (!empty($args['prefs'][$type . '_mbox'])) { - $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; - $_SESSION[$otherKey] = $args['prefs'][$type . '_mbox']; - } - } - return $args; - } + foreach (rcube_storage::$folder_types as $type) { + if (!empty($args['prefs'][$type . '_mbox'])) { + $otherKey = $type . '_mbox' . ident_switch::MY_POSTFIX; + $_SESSION[$otherKey] = $args['prefs'][$type . '_mbox']; + } + } + return $args; + } - $args['abort'] = true; - $args['result'] = false; - return $args; - } + $args['abort'] = true; + $args['result'] = false; + return $args; + } - foreach (rcube_storage::$folder_types as $type) { - if (!empty($args['prefs'][$type . '_mbox'])) { - $key = $type . '_mbox_default' . ident_switch::MY_POSTFIX; - $_SESSION[$key] = $args['prefs'][$type . '_mbox']; - } - } - return $args; - } + foreach (rcube_storage::$folder_types as $type) { + if (!empty($args['prefs'][$type . '_mbox'])) { + $key = $type . '_mbox_default' . ident_switch::MY_POSTFIX; + $_SESSION[$key] = $args['prefs'][$type . '_mbox']; + } + } + return $args; + } - /** - * Reset the baseline for a target account so delta display resets to 0. - * - * For primary account (identId=-1), iid is 0. - * For secondary accounts, look up iid from the ident_switch table. - * - * @param int|null $iid Known iid (0 for primary), or null to look up. - * @param rcmail $rc Roundcube instance. - * @param mixed $identId The ident_switch.id value for secondary accounts. - */ - private function reset_baseline(?int $iid, rcmail $rc, mixed $identId): void - { - if ($iid === null) { - // Look up iid from ident_switch table for secondary account - $sql = 'SELECT iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; - $q = $rc->db->query($sql, $identId, $rc->user->ID); - $r = $rc->db->fetch_assoc($q); - if (!$r) { - return; - } - $iid = (int)$r['iid']; - } + /** + * Reset the baseline for a target account so delta display resets to 0. + * + * For primary account (identId=-1), iid is 0. + * For secondary accounts, look up iid from the ident_switch table. + * + * @param integer|null $iid Known iid (0 for primary), or null to look up. + * @param rcmail $rc Roundcube instance. + * @param mixed $identId The ident_switch.id value for secondary accounts. + */ + private function reset_baseline(?int $iid, rcmail $rc, mixed $identId): void + { + if ($iid === null) { + // Look up iid from ident_switch table for secondary account + $sql = 'SELECT iid FROM ' . $rc->db->table_name(ident_switch::TABLE) . ' WHERE id = ? AND user_id = ?'; + $q = $rc->db->query($sql, $identId, $rc->user->ID); + $r = $rc->db->fetch_assoc($q); + if (!$r) { + return; + } + $iid = (int)$r['iid']; + } - $counts = $_SESSION['ident_switch_counts'] ?? []; - if (isset($counts[$iid])) { - unset($counts[$iid]['baseline']); - $_SESSION['ident_switch_counts'] = $counts; - } - } + $counts = $_SESSION['ident_switch_counts'] ?? []; + if (isset($counts[$iid])) { + unset($counts[$iid]['baseline']); + $_SESSION['ident_switch_counts'] = $counts; + } + } }