Compare commits

...
4 Commits
Author SHA1 Message Date
stuzer05 0795119846 Merge origin/master 2026-10-01 16:09:31 +03:00
stuzer05 86e2db4717 Harden aliasmanager, add live search, copy, ru_RU localization 2026-10-01 16:06:58 +03:00
stuzer05 a185391f9d Commit 2022-12-31 18:35:57 +02:00
stuzer05 47114cfa3b Commit 2022-12-31 18:32:10 +02:00
8 changed files with 384 additions and 271 deletions

No files matched your search

+11
View File
@@ -5,3 +5,14 @@
Provides interface to create mailbox aliases. Meant to use dedicated subdomain for email aliases
Aliases are mailboxes used per-service for privacy and spam control
Lets a user create per-service email aliases (`<service>-<random>@<alias domain>`) that forward to their mailbox. Aliases are stored in the PostfixAdmin `alias` table and can be searched, 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)
+186 -170
View File
@@ -1,53 +1,41 @@
<?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 $postfixadmin_db;
private $db;
/**
* Initializes the plugin.
*/
public function init() {
$this->rcmail = rcube::get_instance();
$this->load_config();
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');
}
if ($this->rcmail->task == "mail" || $this->rcmail->task == "settings") {
$this->include_stylesheet('assets/styles/app.css');
}
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->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 {
// 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");
return $this->view("elastic", "aliasmanager.settings", []);
$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);
/**
* 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));
}
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;
header('Content-Type: application/json; charset=UTF-8');
exit(json_encode($payload));
}
}
+111 -71
View File
@@ -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 = $('<input type="checkbox" class="form-check-input custom-control-input btn-aliasmanager-toggle-alias">')
.attr('id', id)
.attr('data-email', row.email)
.prop('checked', row.active);
const copy = $('<button type="button" class="button btn-sm btn-secondary btn-aliasmanager-copy-alias"></button>')
.attr('data-email', row.email)
.text(labels.copy);
const del = $('<button type="button" class="button btn-sm btn-danger btn-aliasmanager-delete-alias"></button>')
.attr('data-email', row.email)
.text(labels.delete);
return $('<tr class="form-group"></tr>')
.append($('<td></td>').append($('<span class="alias-email"></span>').text(row.email)))
.append($('<td></td>').text(row.created))
.append($('<td></td>').append(
$('<div class="custom-control custom-switch"></div>')
.append(toggle)
.append($('<label class="custom-control-label"></label>').attr('for', id))
))
.append($('<td></td>').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($('<tr class="alias-empty-row"><td colspan="4" class="alias-empty"></td></tr>'));
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('');
call('add-alias', {email: name}, function() {
input.val('');
updateAliasesList();
}
},
dataType: 'json'
});
})
}
$(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('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();
if (confirm(labels.confirm_delete + '\n' + $(this).attr('data-email'))) {
call('delete-alias', {email: $(this).attr('data-email')}, 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()
updateAliasesList();
});
+14
View File
@@ -6,3 +6,17 @@
font-size: 1.1em !important;
content: "\f02c" !important;
}
.aliasmanager-table {
width: 100%;
}
.aliasmanager-table .alias-empty {
text-align: center;
opacity: .6;
}
.aliasmanager-search {
margin: 1rem 0;
max-width: 30rem;
}
+2
View File
@@ -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';
+22
View File
@@ -1,3 +1,25 @@
<?php
$labels['settings_menu_label'] = 'Alias manager';
$labels['new_alias_for'] = 'New alias for';
$labels['service_name'] = 'Service name..';
$labels['add'] = 'Add';
$labels['alias'] = 'Alias';
$labels['created'] = 'Created';
$labels['enabled'] = 'Enabled';
$labels['action'] = 'Action';
$labels['delete'] = 'Delete';
$labels['copy'] = 'Copy';
$labels['copied'] = 'Copied to clipboard';
$labels['empty'] = 'No aliases yet';
$labels['confirm_delete'] = 'Delete this alias? Mail sent to it will no longer be delivered.';
$labels['name_required'] = 'Enter a service name (letters and digits)';
$labels['limit_reached'] = 'Alias limit reached';
$labels['add_failed'] = 'Failed to add alias';
$labels['toggle_failed'] = 'Failed to change alias state';
$labels['delete_failed'] = 'Failed to delete alias';
$labels['list_failed'] = 'Failed to load aliases';
$labels['request_invalid'] = 'Invalid request, reload the page';
$labels['not_configured'] = 'Alias manager is not configured';
$labels['search'] = 'Search aliases..';
$labels['no_matches'] = 'No matching aliases';
+25
View File
@@ -0,0 +1,25 @@
<?php
$labels['settings_menu_label'] = 'Алиасы';
$labels['new_alias_for'] = 'Новый алиас для';
$labels['service_name'] = 'Название сервиса..';
$labels['add'] = 'Добавить';
$labels['alias'] = 'Алиас';
$labels['created'] = 'Создан';
$labels['enabled'] = 'Включён';
$labels['action'] = 'Действие';
$labels['delete'] = 'Удалить';
$labels['copy'] = 'Копировать';
$labels['copied'] = 'Скопировано в буфер обмена';
$labels['empty'] = 'Алиасов пока нет';
$labels['confirm_delete'] = 'Удалить алиас? Письма на него перестанут доставляться.';
$labels['name_required'] = 'Введите название сервиса (буквы и цифры)';
$labels['limit_reached'] = 'Достигнут лимит алиасов';
$labels['add_failed'] = 'Не удалось добавить алиас';
$labels['toggle_failed'] = 'Не удалось изменить состояние алиаса';
$labels['delete_failed'] = 'Не удалось удалить алиас';
$labels['list_failed'] = 'Не удалось загрузить алиасы';
$labels['request_invalid'] = 'Некорректный запрос, перезагрузите страницу';
$labels['not_configured'] = 'Менеджер алиасов не настроен';
$labels['search'] = 'Поиск алиасов..';
$labels['no_matches'] = 'Ничего не найдено';
+13 -30
View File
@@ -1,50 +1,33 @@
<div id='aliasmanager-settings' class="scroller">
<div id="aliasmanager-settings" class="scroller">
<div class="boxcontent formcontent">
<form id="password-form" name="password-form" method="post" action="./?_task=settings&_action=plugin.password-save">
<form id="aliasmanager-form" onsubmit="return false">
<table class="propform">
<tbody>
<tr class="form-group row">
<td class="title col-sm-2">
<label for="alias-new-name" class="col-form-label">New alias for</label>
<label for="alias-new-name" class="col-form-label">[+new_alias_for+]</label>
</td>
<td class="col-sm-2">
<input id="alias-new-name" size="60" required name="alias_new_name" class="form-control" placeholder="Service name.." type="text">
<input id="alias-new-name" size="40" maxlength="40" required name="alias_new_name" class="form-control" placeholder="[+service_name+]" type="text">
</td>
<td class="col-sm-1">
<button class="button submit btn btn-primary btn-aliasmanager-add-alias" type="button">Add</button>
<button class="button submit btn btn-primary btn-aliasmanager-add-alias" type="submit">[+add+]</button>
</td>
</tr>
<tr class="form-group row">
<table>
</tbody>
</table>
<input type="search" id="alias-search" class="form-control aliasmanager-search" placeholder="[+search+]" autocomplete="off">
<table class="records-table aliasmanager-table">
<thead>
<tr>
<th>Alias</th>
<th>Enabled</th>
<th>Action</th>
<th>[+alias+]</th>
<th>[+created+]</th>
<th>[+enabled+]</th>
<th>[+action+]</th>
</tr>
</thead>
<tbody class="alias-list"></tbody>
</table>
</tr>
</tbody>
</table>
</form>
</div>
</div>
<template id="alias-list-row">
<tr class="form-group">
<td>
<input style="background: transparent;border: none;outline: none;width: 100%; cursor: pointer; font-family: monospace;" readonly type="text" value="{email}" onclick="this.select();">
</td>
<td>
<div class="custom-control custom-switch">
<input type="checkbox" id="toggle-active-{i}" value="{active}" {checked} data-email="{email}" class="form-check-input custom-control-input btn-aliasmanager-toggle-alias">
<label for="toggle-active-{i}" class="custom-control-label" title=""></label>
</div>
</td>
<td>
<button class="button btn-sm btn-danger btn-aliasmanager-delete-alias" data-email="{email}" type="button">Delete</button>
</td>
</tr>
</template>