Files
roundcube-aliasmanager/aliasmanager.php
T

262 lines
7.2 KiB
PHP

<?php
/**
* Per-service email alias manager backed by the PostfixAdmin `alias` table.
*
* Config (see config.inc.php.dist):
* - postfixadmin_db_dsn DSN of the PostfixAdmin database
* - alias_email_domain domain the aliases are created in
* - alias_email_hash_len length of the random suffix (default 7)
* - alias_max_per_user maximum number of aliases per user (default 100, 0 = unlimited)
*/
class aliasmanager extends rcube_plugin {
const LABEL_MAX_LEN = 40;
public $task = 'settings';
private $rcmail;
private $db;
public function init() {
$this->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));
}
}