From 86e2db471793fbcabcf10359a5d3e016fe17df86 Mon Sep 17 00:00:00 2001 From: stuzer05 Date: Thu, 1 Oct 2026 16:06:58 +0300 Subject: [PATCH] Harden aliasmanager, add live search, copy, ru_RU localization --- README.md | 12 + aliasmanager.php | 358 ++++++++++++++------------ assets/scripts/app.js | 188 ++++++++------ assets/styles/app.css | 16 +- config.inc.php.dist | 2 + localization/en_US.inc | 24 +- localization/ru_RU.inc | 25 ++ skins/elastic/templates/settings.html | 51 ++-- 8 files changed, 395 insertions(+), 281 deletions(-) create mode 100644 README.md create mode 100644 localization/ru_RU.inc diff --git a/README.md b/README.md new file mode 100644 index 0000000..d8eb646 --- /dev/null +++ b/README.md @@ -0,0 +1,12 @@ +# Roundcube aliasmanager + +Lets a user create per-service email aliases (`-@`) that forward to their mailbox. Aliases are stored in the PostfixAdmin `alias` table and can be disabled, copied or deleted from Settings. + +## Config + +Copy `config.inc.php.dist` to `config.inc.php`: + +- `postfixadmin_db_dsn` - PostfixAdmin database DSN +- `alias_email_domain` - domain aliases are created in +- `alias_email_hash_len` - random suffix length (default 7, minimum 4) +- `alias_max_per_user` - alias limit per user (default 100, 0 = unlimited) diff --git a/aliasmanager.php b/aliasmanager.php index 608d058..5d50d54 100644 --- a/aliasmanager.php +++ b/aliasmanager.php @@ -1,53 +1,41 @@ rcmail = rcube::get_instance(); $this->load_config(); + $this->add_texts('localization/'); - if ($dsn = $this->rcmail->config->get('postfixadmin_db_dsn')) { - $this->postfixadmin_db = rcube_db::factory($dsn, '', false); - } else { - throw new \Exception('cannot connect ot postfix db'); - } + $this->include_stylesheet('assets/styles/app.css'); - if ($this->rcmail->task == "mail" || $this->rcmail->task == "settings") { - $this->include_stylesheet('assets/styles/app.css'); - } + $this->add_hook('settings_actions', [$this, 'hookSettingsActions']); - if ($this->rcmail->task == "settings") { - $this->add_hook("settings_actions", [$this, "hookSettingsActions"]); - $this->register_action('plugin.aliasmanager', [$this, "onShowSettingsPage"]); - - $this->add_texts('localization/'); - - switch ($this->rcmail->action) { - case 'plugin.aliasmanager-get-alias-list': - $this->onGetAliasList(); - break; - case 'plugin.aliasmanager-add-alias': - $this->onAddAlias(); - break; - case 'plugin.aliasmanager-toggle-alias': - $this->onToggleAlias(); - break; - case 'plugin.aliasmanager-delete-alias': - $this->onDeleteAlias(); - break; - } - } + $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 { - // add the menu item to the settings sidebar $arg['actions'][] = [ 'action' => 'plugin.aliasmanager', 'class' => 'aliasmanager', @@ -59,187 +47,215 @@ class aliasmanager extends rcube_plugin { return $arg; } - /** - * Renders and returns the settings.html view. - * @return mixed - */ - public function settingsPageHandler() { + public function onShowSettingsPage() { $this->include_script('assets/scripts/app.js'); - $this->rcmail->output->add_label("settings_menu_label"); + $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); - return $this->view("elastic", "aliasmanager.settings", []); - } - - /** - * Creates and outputs the settings page. - */ - public function onShowSettingsPage() { $this->register_handler('plugin.body', [$this, 'settingsPageHandler']); - $this->rcmail->output->set_pagetitle($this->gettext('aliasmanager')); + $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() { - $alias_list = []; - $result = $this->postfixadmin_db->query('SELECT address as email, goto as users, active FROM alias WHERE address != goto AND goto = ? AND domain = ?', $this->rcmail->user->get_username(), $this->rcmail->config->get('alias_email_domain')); - foreach ($result as $row) { - $alias_list[] = [ - 'email' => $row['email'], - 'active' => $row['active'], + 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->sendResponse(true, [ - 'data' => [ - 'alias_list' => $alias_list, - ], - ]); + $this->respond(['alias_list' => $list]); } public function onAddAlias() { - $email = $_POST['email'] ?? null; - if (empty($email)) { - $this->sendResponse(false, [ - 'msg' => 'Email not set', - ]); + if (!$this->guard()) { return; } - $email_alias_domain = $this->rcmail->config->get('alias_email_domain'); - - $email = $email.'-'.$this->generateRandomEmailAliasHash($this->rcmail->config->get('alias_email_hash_len', 7)).'@'.$email_alias_domain; - - $error = $this->postfixadmin_db->query('INSERT INTO alias (address, goto, domain, created, modified, active) VALUES (?, ?, ?, NOW(), NOW(), 1)', [$email, $this->rcmail->user->get_username(), $email_alias_domain]); - if (!$error) { - $this->sendResponse(false, [ - 'msg' => 'failed to add alias', // $this->postfixadmin_db->is_error() - ]); - return; + $label = $this->normalizeLabel((string)rcube_utils::get_input_value('email', rcube_utils::INPUT_POST)); + if ($label === '') { + $this->fail('name_required'); } - $this->sendResponse(true, []); + $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() { - $state = $_POST['state'] == 'true' ? 1 : 0; - $email = $_POST['email']; - - if (empty($email)) { - $this->sendResponse(false, [ - 'msg' => 'Email not set', - ]); + if (!$this->guard()) { return; } - $email_alias_domain = $this->rcmail->config->get('alias_email_domain'); + $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; - $error = $this->postfixadmin_db->query('UPDATE alias SET active = ? WHERE address = ? AND goto = ? AND domain = ?', [$state, $email, $this->rcmail->user->get_username(), $email_alias_domain]); - if (!$error) { - $this->sendResponse(false, [ - 'msg' => 'failed to toggle alias', // $this->postfixadmin_db->is_error() - ]); - return; + $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->sendResponse(true, []); + $this->respond([]); } public function onDeleteAlias() { - $email = $_POST['email']; - if (empty($email)) { - $this->sendResponse(false, [ - 'msg' => 'Email not set', - ]); + if (!$this->guard()) { return; } - $email_alias_domain = $this->rcmail->config->get('alias_email_domain'); + $email = trim((string)rcube_utils::get_input_value('email', rcube_utils::INPUT_POST)); - $error = $this->postfixadmin_db->query('DELETE FROM alias WHERE address = ? AND goto = ? AND domain = ?', [$email, $this->rcmail->user->get_username(), $email_alias_domain]); - if (!$error) { - $this->sendResponse(false, [ - 'msg' => 'failed to toggle alias', // $this->postfixadmin_db->is_error() - ]); - return; + $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->sendResponse(true, []); + $this->respond([]); } /** - * Sends ajax response in json format. - * - * IMPORTANT: When sending an error with an error message, use this format: - * sendResponse(true, array('success' => false, 'errorMessage' => $message, 'other data'...) - * This is because the standard way of setting $success and $errorMessage won't work properly with non-English - * character sets (when the error is sent using http/1.0 500) - * - * @param bool $success - * @param array $data + * Rejects requests without a valid CSRF token and with a broken configuration. */ - private function sendResponse($success, $data = [], $errorMessage = false) { - if ($this->unitTest) { - return ["success" => $success, "data" => $data, "errorMessage" => $data['errorMessage']]; + 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'); } - if (ob_get_contents()) { + 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(); } - if (!is_array($data)) { - $data = []; - } - - if (!isset($data['success'])) { - $data['success'] = (bool)$success; - } - - if ($success) { - exit(json_encode($data)); - } - - if (empty($errorMessage)) { - $errorMessage = empty($data['errorMessage']) ? "Server error" : $data['errorMessage']; - } - - exit(@header("HTTP/1.0 500 ".$errorMessage)); + header('Content-Type: application/json; charset=UTF-8'); + exit(json_encode($payload)); } - - private function view($skin, $view, $data = false) { - if (empty($data) || !is_array($data)) { - $data = []; - } - - $parts = explode(".", $view); - $plugin = $parts[0]; - - unset($parts[0]); - $html = file_get_contents(__DIR__."/../$plugin/skins/$skin/templates/".implode(".", $parts).".html"); - - while (($i = strrpos($html, "[+")) !== false && ($j = strrpos($html, "+]")) !== false) { - $html = substr_replace($html, xrc()->gettext(substr($html, $i + 2, $j - $i - 2)), $i, $j - $i + 2); - } - - // replace our custom tags that can contain html tags - foreach ($data as $key => $val) { - if (is_string($val)) { - $html = str_replace("[~".$key."~]", $val, $html); - } else if (is_array($val)) { - $html = str_replace("[~".$key."~]", @json_encode($val), $html); - } - } - - return $html; - } - - private function generateRandomEmailAliasHash($length = 10) { - $characters = '0123456789abcdefghijklmnopqrstuvwxyz'; - $charactersLength = strlen($characters); - $randomString = ''; - for ($i = 0; $i < $length; $i++) { - $randomString .= $characters[rand(0, $charactersLength - 1)]; - } - return $randomString; - } -} \ No newline at end of file +} diff --git a/assets/scripts/app.js b/assets/scripts/app.js index e1c0e9d..0e4a001 100644 --- a/assets/scripts/app.js +++ b/assets/scripts/app.js @@ -1,96 +1,136 @@ $(function() { - function updateAliasesList() { + const labels = rcmail.env.aliasmanager_labels || {}; + + function call(action, data, onSuccess) { + data = $.extend({_token: rcmail.env.request_token}, data); + $.ajax({ type: 'POST', - url: '/?_task=settings&_action=plugin.aliasmanager-get-alias-list', + url: rcmail.url('plugin.aliasmanager-' + action).replace(/&_unlock=[^&]*/, ''), + data: data, + dataType: 'json', success: function(response) { if (response.success) { - $('[name=alias_new_name]').val(''); - - // Clear table - $('.alias-list').html(''); - - // Make template - let template = $('template#alias-list-row').html(); - - for (let i = 0; i < response.data.alias_list.length; i++) { - const row = response.data.alias_list[i]; - - let tpl = template.replaceAll('{i}', i); - tpl = tpl.replaceAll('{email}', row.email); - tpl = tpl.replaceAll('{active}', row.active == 1); - tpl = tpl.replaceAll('{checked}', row.active == 1 ? 'checked' : ''); - $('.alias-list').append(tpl); - } + onSuccess(response.data || {}); + } else { + rcmail.display_message(response.message, 'error'); + updateAliasesList(); } }, - dataType: 'json' + error: function() { + rcmail.display_message(labels.list_failed, 'error'); + } }); } - $(document).on('click', '.btn-aliasmanager-add-alias', function() { - const email = $('[name=alias_new_name]').val(); - if (!/[a-z0-9]/.test(email)) { + function renderRow(row, i) { + const id = 'toggle-active-' + i; + + const toggle = $('') + .attr('id', id) + .attr('data-email', row.email) + .prop('checked', row.active); + + const copy = $('') + .attr('data-email', row.email) + .text(labels.copy); + + const del = $('') + .attr('data-email', row.email) + .text(labels.delete); + + return $('') + .append($('').append($('').text(row.email))) + .append($('').text(row.created)) + .append($('').append( + $('
') + .append(toggle) + .append($('').attr('for', id)) + )) + .append($('').append(copy, ' ', del)); + } + + function updateAliasesList() { + call('get-alias-list', {}, function(data) { + const list = $('.alias-list').empty(); + + data.alias_list.forEach(function(row, i) { + list.append(renderRow(row, i)); + }); + + list.append($('')); + applyFilter(); + }); + } + + // Hides rows that do not match the search query, shows a placeholder row when nothing is left + function applyFilter() { + const query = $('#alias-search').val().trim().toLowerCase(); + const rows = $('.alias-list tr').not('.alias-empty-row'); + let visible = 0; + + rows.each(function() { + const match = !query || $(this).find('.alias-email').text().toLowerCase().indexOf(query) !== -1; + $(this).toggle(match); + if (match) { + visible++; + } + }); + + $('.alias-empty-row') + .toggle(visible === 0) + .find('td') + .text(rows.length ? labels.no_matches : labels.empty); + } + + let searchTimer; + $(document).on('input', '#alias-search', function() { + clearTimeout(searchTimer); + searchTimer = setTimeout(applyFilter, 250); + }); + + function addAlias() { + const input = $('[name=alias_new_name]'); + const name = input.val().trim(); + + if (!/[a-z0-9]/i.test(name)) { + rcmail.display_message(labels.name_required, 'warning'); return; } - $.ajax({ - type: 'POST', - url: '/?_task=settings&_action=plugin.aliasmanager-add-alias', - data: { - email: email, - }, - success: function(response) { - if (response.success) { - $('[name=alias_new_name]').val(''); - - updateAliasesList(); - } - }, - dataType: 'json' + call('add-alias', {email: name}, function() { + input.val(''); + updateAliasesList(); }); - }) + } + + $(document).on('submit', '#aliasmanager-form', function(e) { + e.preventDefault(); + addAlias(); + }); $(document).on('change', '.btn-aliasmanager-toggle-alias', function() { - const email = $(this).attr('data-email'); - const isOn = $(this).is(':checked'); - - $.ajax({ - type: 'POST', - url: '/?_task=settings&_action=plugin.aliasmanager-toggle-alias', - data: { - state: isOn, - email: email, - }, - success: function(response) { - if (response.success) { - updateAliasesList(); - } - }, - dataType: 'json' - }); - }) + call('toggle-alias', { + email: $(this).attr('data-email'), + state: $(this).is(':checked'), + }, function() {}); + }); $(document).on('click', '.btn-aliasmanager-delete-alias', function() { - const email = $(this).attr('data-email'); + if (confirm(labels.confirm_delete + '\n' + $(this).attr('data-email'))) { + call('delete-alias', {email: $(this).attr('data-email')}, updateAliasesList); + } + }); - if (confirm('You really want to delete ' + email + '?')) { - $.ajax({ - type: 'POST', - url: '/?_task=settings&_action=plugin.aliasmanager-delete-alias', - data: { - email: email, - }, - success: function(response) { - if (response.success) { - updateAliasesList(); - } - }, - dataType: 'json' + $(document).on('click', '.btn-aliasmanager-copy-alias', function() { + const text = $(this).attr('data-email'); + + if (navigator.clipboard) { + navigator.clipboard.writeText(text).then(function() { + rcmail.display_message(labels.copied, 'confirmation'); }); } - }) + }); - // Init - updateAliasesList() -}); \ No newline at end of file + updateAliasesList(); +}); diff --git a/assets/styles/app.css b/assets/styles/app.css index 8d34959..8d9ebe4 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -5,4 +5,18 @@ .aliasmanager :before { font-size: 1.1em !important; content: "\f02c" !important; -} \ No newline at end of file +} + +.aliasmanager-table { + width: 100%; +} + +.aliasmanager-table .alias-empty { + text-align: center; + opacity: .6; +} + +.aliasmanager-search { + margin: 1rem 0; + max-width: 30rem; +} diff --git a/config.inc.php.dist b/config.inc.php.dist index 9f1f7e3..31f52e8 100644 --- a/config.inc.php.dist +++ b/config.inc.php.dist @@ -4,4 +4,6 @@ $config['alias_email_domain'] = 'social.mail.example.com'; $config['alias_email_hash_len'] = 7; +$config['alias_max_per_user'] = 100; + $config['postfixadmin_db_dsn'] = 'mysql://user:pass@host/db'; \ No newline at end of file diff --git a/localization/en_US.inc b/localization/en_US.inc index 9751ac1..bd1f660 100644 --- a/localization/en_US.inc +++ b/localization/en_US.inc @@ -1,3 +1,25 @@ +
-
+ - -
- + - + - +
- - - - - - - - -
AliasEnabledAction
- + + + + + + + + + + + +
[+alias+][+created+][+enabled+][+action+]
- - \ No newline at end of file