rcmail = rcube::get_instance(); $this->load_config(); $this->add_texts('localization/'); $this->include_stylesheet('assets/styles/app.css'); $this->add_hook('settings_actions', [$this, 'hookSettingsActions']); $this->register_action('plugin.aliasmanager', [$this, 'onShowSettingsPage']); $this->register_action('plugin.aliasmanager-get-alias-list', [$this, 'onGetAliasList']); $this->register_action('plugin.aliasmanager-add-alias', [$this, 'onAddAlias']); $this->register_action('plugin.aliasmanager-toggle-alias', [$this, 'onToggleAlias']); $this->register_action('plugin.aliasmanager-delete-alias', [$this, 'onDeleteAlias']); } public function hookSettingsActions($arg): array { $arg['actions'][] = [ 'action' => 'plugin.aliasmanager', 'class' => 'aliasmanager', 'label' => 'settings_menu_label', 'title' => 'settings_menu_label', 'domain' => 'aliasmanager', ]; return $arg; } public function onShowSettingsPage() { $this->include_script('assets/scripts/app.js'); $labels = []; foreach (['add_failed', 'toggle_failed', 'delete_failed', 'list_failed', 'name_required', 'confirm_delete', 'copied', 'copy', 'delete', 'empty', 'no_matches'] as $key) { $labels[$key] = $this->gettext($key); } $this->rcmail->output->set_env('aliasmanager_labels', $labels); $this->register_handler('plugin.body', [$this, 'settingsPageHandler']); $this->rcmail->output->set_pagetitle($this->gettext('settings_menu_label')); $this->rcmail->output->send('plugin'); } public function settingsPageHandler() { $html = file_get_contents(__DIR__.'/skins/elastic/templates/settings.html'); return preg_replace_callback('/\[\+([a-z_]+)\+\]/', function ($m) { return rcube::Q($this->gettext($m[1])); }, $html); } public function onGetAliasList() { if (!$this->guard()) { return; } $result = $this->db()->query( 'SELECT address, active, created FROM alias WHERE address != goto AND goto = ? AND domain = ? ORDER BY created DESC, address', $this->username(), $this->aliasDomain() ); if ($this->db()->is_error($result)) { $this->fail('list_failed'); } $list = []; while ($row = $this->db()->fetch_assoc($result)) { $list[] = [ 'email' => $row['address'], 'active' => (int)$row['active'] === 1, 'created' => substr($row['created'], 0, 10), ]; } $this->respond(['alias_list' => $list]); } public function onAddAlias() { if (!$this->guard()) { return; } $label = $this->normalizeLabel((string)rcube_utils::get_input_value('email', rcube_utils::INPUT_POST)); if ($label === '') { $this->fail('name_required'); } $domain = $this->aliasDomain(); $max = (int)$this->rcmail->config->get('alias_max_per_user', 100); if ($max > 0 && $this->countAliases() >= $max) { $this->fail('limit_reached'); } $hash_len = max(4, (int)$this->rcmail->config->get('alias_email_hash_len', 7)); // retry on the (unlikely) address collision for ($attempt = 0; $attempt < 5; $attempt++) { $email = $label.'-'.$this->randomHash($hash_len).'@'.$domain; $exists = $this->db()->query('SELECT 1 FROM alias WHERE address = ?', $email); if ($this->db()->fetch_array($exists)) { continue; } $result = $this->db()->query( 'INSERT INTO alias (address, goto, domain, created, modified, active) VALUES (?, ?, ?, NOW(), NOW(), 1)', $email, $this->username(), $domain ); if ($this->db()->affected_rows($result) < 1) { $this->fail('add_failed'); } $this->respond(['email' => $email]); } $this->fail('add_failed'); } public function onToggleAlias() { if (!$this->guard()) { return; } $email = trim((string)rcube_utils::get_input_value('email', rcube_utils::INPUT_POST)); $state = rcube_utils::get_input_value('state', rcube_utils::INPUT_POST) === 'true' ? 1 : 0; $result = $this->db()->query( 'UPDATE alias SET active = ?, modified = NOW() WHERE address = ? AND goto = ? AND domain = ? AND address != goto', $state, $email, $this->username(), $this->aliasDomain() ); if ($this->db()->is_error($result)) { $this->fail('toggle_failed'); } $this->respond([]); } public function onDeleteAlias() { if (!$this->guard()) { return; } $email = trim((string)rcube_utils::get_input_value('email', rcube_utils::INPUT_POST)); $result = $this->db()->query( 'DELETE FROM alias WHERE address = ? AND goto = ? AND domain = ? AND address != goto', $email, $this->username(), $this->aliasDomain() ); if ($this->db()->is_error($result) || $this->db()->affected_rows($result) < 1) { $this->fail('delete_failed'); } $this->respond([]); } /** * Rejects requests without a valid CSRF token and with a broken configuration. */ private function guard(): bool { if (!$this->rcmail->check_request(rcube_utils::INPUT_POST)) { $this->fail('request_invalid'); } if ($this->aliasDomain() === '' || !$this->db()) { $this->fail('not_configured'); } return true; } private function db() { if ($this->db === null) { $dsn = $this->rcmail->config->get('postfixadmin_db_dsn'); $this->db = $dsn ? rcube_db::factory($dsn, '', false) : false; } return $this->db; } private function username(): string { return $this->rcmail->user->get_username(); } private function aliasDomain(): string { return (string)$this->rcmail->config->get('alias_email_domain', ''); } private function countAliases(): int { $result = $this->db()->query('SELECT COUNT(*) FROM alias WHERE address != goto AND goto = ? AND domain = ?', $this->username(), $this->aliasDomain()); $row = $this->db()->fetch_array($result); return (int)($row[0] ?? 0); } /** * Reduces user input to a safe email local part: [a-z0-9._-], at most LABEL_MAX_LEN chars. */ private function normalizeLabel(string $input): string { $label = strtolower(trim($input)); $label = preg_replace('/[^a-z0-9._-]+/', '-', $label); $label = preg_replace('/([._-])\1+/', '$1', $label); $label = substr($label, 0, self::LABEL_MAX_LEN); return trim($label, '._-'); } private function randomHash(int $length): string { $characters = '0123456789abcdefghijklmnopqrstuvwxyz'; $max = strlen($characters) - 1; $hash = ''; for ($i = 0; $i < $length; $i++) { $hash .= $characters[random_int(0, $max)]; } return $hash; } private function respond(array $data) { $this->send(['success' => true, 'data' => $data]); } private function fail(string $label) { $this->send(['success' => false, 'message' => $this->gettext($label)]); } private function send(array $payload) { if (ob_get_length()) { @ob_end_clean(); } header('Content-Type: application/json; charset=UTF-8'); exit(json_encode($payload)); } }