529 lines
19 KiB
PHP
529 lines
19 KiB
PHP
<?php
|
|
|
|
/**
|
|
* ident_switch - Roundcube plugin for fast switching between accounts.
|
|
*
|
|
* Copyright (C) 2016-2022 Boris Gulay
|
|
* Copyright (C) 2019 Christian Landvogt
|
|
* Copyright (C) 2021 Gergely Papp
|
|
* Copyright (C) 2022 Mickael
|
|
* Copyright (C) 2026 Gecka
|
|
*
|
|
* Original code licensed under GPL-3.0+.
|
|
* New contributions licensed under AGPL-3.0+.
|
|
*
|
|
* @url https://github.com/Gecka-apps/ident_switch
|
|
*/
|
|
|
|
require_once __DIR__ . '/lib/IdentSwitchPreconfig.php';
|
|
require_once __DIR__ . '/lib/IdentSwitchForm.php';
|
|
require_once __DIR__ . '/lib/IdentSwitchSwitcher.php';
|
|
require_once __DIR__ . '/lib/IdentSwitchChecker.php';
|
|
|
|
/**
|
|
* Roundcube plugin for fast switching between multiple IMAP accounts.
|
|
*
|
|
* Allows users to configure and switch between multiple mail accounts
|
|
* (including remote) within a single Roundcube session, with alias support.
|
|
*
|
|
* @url https://github.com/Gecka-apps/ident_switch
|
|
*/
|
|
class ident_switch extends rcube_plugin
|
|
{
|
|
/** @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,
|
|
};
|
|
|
|
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);
|
|
}
|
|
|
|
$primaryIdentity = $rc->user->get_identity();
|
|
$primaryName = !empty($primaryIdentity['name']) ? $primaryIdentity['name'] : null;
|
|
$accNames = [$_SESSION['global_alias'] ?? $primaryName ?? $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[] = $lbl;
|
|
}
|
|
|
|
if (count($accValues) <= 1) {
|
|
return;
|
|
}
|
|
|
|
$this->include_stylesheet('ident_switch.css');
|
|
$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 = '<span id="ident-switch-wrapper" class="ident-switch-wrapper">'
|
|
. $select->show([$accSelected])
|
|
. '<span id="ident-switch-badge" class="ident-switch-badge" style="display:none"></span>'
|
|
. '</span>';
|
|
|
|
$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);
|
|
}
|
|
}
|
|
}
|