[Web][Rspamd] implement custom mailbox attributes and improve domain wide footer

This commit is contained in:
FreddleSpl0it 2023-11-23 16:12:43 +01:00
parent 392967d664
commit d2e3867893
No known key found for this signature in database
GPG Key ID: 00E14E7634F4BEC5
14 changed files with 530 additions and 69 deletions

View File

@ -79,6 +79,9 @@ EOF
redis-cli -h redis-mailcow SLAVEOF NO ONE redis-cli -h redis-mailcow SLAVEOF NO ONE
fi fi
# Provide additional lua modules
ln -s /usr/lib/$(uname -m)-linux-gnu/liblua5.1-cjson.so.0.0.0 /usr/lib/rspamd/cjson.so
chown -R _rspamd:_rspamd /var/lib/rspamd \ chown -R _rspamd:_rspamd /var/lib/rspamd \
/etc/rspamd/local.d \ /etc/rspamd/local.d \
/etc/rspamd/override.d \ /etc/rspamd/override.d \

View File

@ -0,0 +1,89 @@
<?php
// File size is limited by Nginx site to 10M
// To speed things up, we do not include prerequisites
header('Content-Type: text/plain');
require_once "vars.inc.php";
// Do not show errors, we log to using error_log
ini_set('error_reporting', 0);
// Init database
//$dsn = $database_type . ':host=' . $database_host . ';dbname=' . $database_name;
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
$opt = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
];
try {
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
}
catch (PDOException $e) {
error_log("FOOTER: " . $e . PHP_EOL);
http_response_code(501);
exit;
}
if (!function_exists('getallheaders')) {
function getallheaders() {
if (!is_array($_SERVER)) {
return array();
}
$headers = array();
foreach ($_SERVER as $name => $value) {
if (substr($name, 0, 5) == 'HTTP_') {
$headers[str_replace(' ', '-', ucwords(strtolower(str_replace('_', ' ', substr($name, 5)))))] = $value;
}
}
return $headers;
}
}
// Read headers
$headers = getallheaders();
// Get Domain
$domain = $headers['Domain'];
// Get Username
$username = $headers['Username'];
// define empty footer
$empty_footer = json_encode(array(
'html' => '',
'plain' => '',
'vars' => array()
));
error_log("FOOTER: checking for domain " . $domain . " and user " . $username . PHP_EOL);
try {
$stmt = $pdo->prepare("SELECT `plain`, `html`, `mbox_exclude` FROM `domain_wide_footer`
WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain
));
$footer = $stmt->fetch(PDO::FETCH_ASSOC);
if (in_array($username, json_decode($footer['mbox_exclude']))){
$footer = false;
}
if (empty($footer)){
echo $empty_footer;
exit;
}
error_log("FOOTER: " . json_encode($footer) . PHP_EOL);
$stmt = $pdo->prepare("SELECT `custom_attributes` FROM `mailbox` WHERE `username` = :username");
$stmt->execute(array(
':username' => $username
));
$custom_attributes = $stmt->fetch(PDO::FETCH_ASSOC)['custom_attributes'];
if (empty($custom_attributes)){
$custom_attributes = (object)array();
}
}
catch (Exception $e) {
error_log("FOOTER: " . $e->getMessage() . PHP_EOL);
http_response_code(502);
exit;
}
// return footer
$footer["vars"] = $custom_attributes;
echo json_encode($footer);

View File

@ -527,12 +527,11 @@ rspamd_config:register_symbol({
name = 'MOO_FOOTER', name = 'MOO_FOOTER',
type = 'prefilter', type = 'prefilter',
callback = function(task) callback = function(task)
local cjson = require "cjson"
local lua_mime = require "lua_mime" local lua_mime = require "lua_mime"
local lua_util = require "lua_util" local lua_util = require "lua_util"
local rspamd_logger = require "rspamd_logger" local rspamd_logger = require "rspamd_logger"
local rspamd_redis = require "rspamd_redis" local rspamd_http = require "rspamd_http"
local ucl = require "ucl"
local redis_params = rspamd_parse_redis_server('footer')
local envfrom = task:get_from(1) local envfrom = task:get_from(1)
local uname = task:get_user() local uname = task:get_user()
if not envfrom or not uname then if not envfrom or not uname then
@ -541,6 +540,7 @@ rspamd_config:register_symbol({
local uname = uname:lower() local uname = uname:lower()
local env_from_domain = envfrom[1].domain:lower() -- get smtp from domain in lower case local env_from_domain = envfrom[1].domain:lower() -- get smtp from domain in lower case
-- determine newline type
local function newline(task) local function newline(task)
local t = task:get_newlines_type() local t = task:get_newlines_type()
@ -552,20 +552,19 @@ rspamd_config:register_symbol({
return '\r\n' return '\r\n'
end end
local function redis_cb_footer(err, data) -- retrieve footer
local function footer_cb(err_message, code, data, headers)
if err or type(data) ~= 'string' then if err or type(data) ~= 'string' then
rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err) rspamd_logger.infox(rspamd_config, "domain wide footer request for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err)
else else
-- parse json string -- parse json string
local parser = ucl.parser() local footer = cjson.decode(data)
local res,err = parser:parse_string(data) if not footer then
if not res then
rspamd_logger.infox(rspamd_config, "parsing domain wide footer for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err) rspamd_logger.infox(rspamd_config, "parsing domain wide footer for user %s returned invalid or empty data (\"%s\") or error (\"%s\")", uname, data, err)
else else
local footer = parser:get_object()
if footer and type(footer) == "table" and (footer.html and footer.html ~= "" or footer.plain and footer.plain ~= "") then if footer and type(footer) == "table" and (footer.html and footer.html ~= "" or footer.plain and footer.plain ~= "") then
rspamd_logger.infox(rspamd_config, "found domain wide footer for user %s: html=%s, plain=%s", uname, footer.html, footer.plain) rspamd_logger.infox(rspamd_config, "found domain wide footer for user %s: html=%s, plain=%s, vars=%s", uname, footer.html, footer.plain, footer.vars)
local envfrom_mime = task:get_from(2) local envfrom_mime = task:get_from(2)
local from_name = "" local from_name = ""
@ -575,6 +574,7 @@ rspamd_config:register_symbol({
from_name = envfrom[1].name from_name = envfrom[1].name
end end
-- default replacements
local replacements = { local replacements = {
auth_user = uname, auth_user = uname,
from_user = envfrom[1].user, from_user = envfrom[1].user,
@ -582,10 +582,20 @@ rspamd_config:register_symbol({
from_addr = envfrom[1].addr, from_addr = envfrom[1].addr,
from_domain = envfrom[1].domain:lower() from_domain = envfrom[1].domain:lower()
} }
if footer.html then -- add custom mailbox attributes
if footer.vars and type(footer.vars) == "string" then
local footer_vars = cjson.decode(footer.vars)
if type(footer_vars) == "table" then
for key, value in pairs(footer_vars) do
replacements[key] = value
end
end
end
if footer.html and footer.html ~= "" then
footer.html = lua_util.jinja_template(footer.html, replacements, true) footer.html = lua_util.jinja_template(footer.html, replacements, true)
end end
if footer.plain then if footer.plain and footer.plain ~= "" then
footer.plain = lua_util.jinja_template(footer.plain, replacements, true) footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
end end
@ -653,17 +663,14 @@ rspamd_config:register_symbol({
end end
end end
local redis_ret_footer = rspamd_redis_make_request(task, -- fetch footer
redis_params, -- connect params rspamd_http.request({
env_from_domain, -- hash key task=task,
false, -- is write url='http://nginx:8081/footer.php',
redis_cb_footer, --callback body='',
'HGET', -- command callback=footer_cb,
{"DOMAIN_WIDE_FOOTER", env_from_domain} -- arguments headers={Domain=env_from_domain,Username=uname},
) })
if not redis_ret_footer then
rspamd_logger.infox(rspamd_config, "cannot make request to load footer for domain")
end
return true return true
end, end,

View File

@ -3137,6 +3137,86 @@ paths:
type: string type: string
type: object type: object
summary: Update domain summary: Update domain
/api/v1/edit/domain/footer:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- mailbox
- edit
- domain_wide_footer
- domains:
- mailcow.tld
html: "<br>foo {= foo =}"
plain: "<foo {= foo =}"
mbox_exclude:
- moo@mailcow.tld
- null
msg:
- domain_footer_modified
- mailcow.tld
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- error
type: string
type: object
description: OK
headers: {}
tags:
- Domains
description: >-
You can update the footer of one or more domains per request.
operationId: Update domain wide footer
requestBody:
content:
application/json:
schema:
example:
attr:
html: "<br>foo {= foo =}"
plain: "foo {= foo =}"
mbox_exclude:
- moo@mailcow.tld
items: mailcow.tld
properties:
attr:
properties:
html:
description: Footer text in HTML format
type: string
plain:
description: Footer text in PLAIN text format
type: string
mbox_exclude:
description: Array of mailboxes to exclude from domain wide footer
type: object
type: object
items:
description: contains a list of domain names where you want to update the footer
type: array
items:
type: string
type: object
summary: Update domain wide footer
/api/v1/edit/fail2ban: /api/v1/edit/fail2ban:
post: post:
responses: responses:
@ -3336,6 +3416,86 @@ paths:
type: object type: object
type: object type: object
summary: Update mailbox summary: Update mailbox
/api/v1/edit/mailbox/custom-attribute:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- mailbox
- edit
- mailbox_custom_attribute
- mailboxes:
- moo@mailcow.tld
attribute:
- role
- foo
value:
- cow
- bar
- null
msg:
- mailbox_modified
- moo@mailcow.tld
type: success
schema:
properties:
log:
description: contains request object
items: {}
type: array
msg:
items: {}
type: array
type:
enum:
- success
- danger
- error
type: string
type: object
description: OK
headers: {}
tags:
- Mailboxes
description: >-
You can update custom attributes of one or more mailboxes per request.
operationId: Update mailbox custom attributes
requestBody:
content:
application/json:
schema:
example:
attr:
attribute:
- role
- foo
value:
- cow
- bar
items:
- moo@mailcow.tld
properties:
attr:
properties:
attribute:
description: Array of attribute keys
type: object
value:
description: Array of attribute values
type: object
type: object
items:
description: contains list of mailboxes you want update
type: object
type: object
summary: Update mailbox custom attributes
/api/v1/edit/mailq: /api/v1/edit/mailq:
post: post:
responses: responses:
@ -5581,6 +5741,7 @@ paths:
sogo_access: "1" sogo_access: "1"
tls_enforce_in: "0" tls_enforce_in: "0"
tls_enforce_out: "0" tls_enforce_out: "0"
custom_attributes: {}
domain: domain3.tld domain: domain3.tld
is_relayed: 0 is_relayed: 0
local_part: info local_part: info

View File

@ -58,6 +58,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
'dkim' => dkim('details', $domain), 'dkim' => dkim('details', $domain),
'domain_details' => $result, 'domain_details' => $result,
'domain_footer' => $domain_footer, 'domain_footer' => $domain_footer,
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address')
]; ];
} }
} }
@ -218,6 +220,7 @@ $js_minifier->add('/web/js/site/pwgen.js');
$template_data['result'] = $result; $template_data['result'] = $result;
$template_data['return_to'] = $_SESSION['return_to']; $template_data['return_to'] = $_SESSION['return_to'];
$template_data['lang_user'] = json_encode($lang['user']); $template_data['lang_user'] = json_encode($lang['user']);
$template_data['lang_admin'] = json_encode($lang['admin']);
$template_data['lang_datatables'] = json_encode($lang['datatables']); $template_data['lang_datatables'] = json_encode($lang['datatables']);
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';

View File

@ -3264,6 +3264,62 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
); );
return true; return true;
break; break;
case 'mailbox_custom_attribute':
$_data['attribute'] = isset($_data['attribute']) ? $_data['attribute'] : array();
$_data['attribute'] = is_array($_data['attribute']) ? $_data['attribute'] : array($_data['attribute']);
$_data['attribute'] = array_map(function($value) { return str_replace(' ', '', $value); }, $_data['attribute']);
$_data['value'] = isset($_data['value']) ? $_data['value'] : array();
$_data['value'] = is_array($_data['value']) ? $_data['value'] : array($_data['value']);
$attributes = (object)array_combine($_data['attribute'], $_data['value']);
$mailboxes = is_array($_data['mailboxes']) ? $_data['mailboxes'] : array($_data['mailboxes']);
foreach ($mailboxes as $mailbox) {
if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => array('username_invalid', $mailbox)
);
continue;
}
$is_now = mailbox('get', 'mailbox_details', $mailbox);
if(!empty($is_now)){
if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $is_now['domain'])) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'access_denied'
);
continue;
}
}
else {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'access_denied'
);
continue;
}
$stmt = $pdo->prepare("UPDATE `mailbox`
SET `custom_attributes` = :custom_attributes
WHERE username = :username");
$stmt->execute(array(
":username" => $mailbox,
":custom_attributes" => json_encode($attributes)
));
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => array('mailbox_modified', $mailbox)
);
}
return true;
break;
case 'resource': case 'resource':
if (!is_array($_data['name'])) { if (!is_array($_data['name'])) {
$names = array(); $names = array();
@ -3343,44 +3399,79 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
); );
} }
break; break;
case 'domain_wide_footer': case 'domain_wide_footer':
$domain = idn_to_ascii(strtolower(trim($_data['domain'])), 0, INTL_IDNA_VARIANT_UTS46); if (!is_array($_data['domains'])) {
if (!is_valid_domain_name($domain)) { $domains = array();
$_SESSION['return'][] = array( $domains[] = $_data['domains'];
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'domain_invalid'
);
return false;
} }
if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) { else {
$_SESSION['return'][] = array( $domains = $_data['domains'];
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'access_denied'
);
return false;
} }
$footers = array(); foreach ($domains as $domain) {
$footers['html'] = isset($_data['footer_html']) ? $_data['footer_html'] : ''; $domain = idn_to_ascii(strtolower(trim($domain)), 0, INTL_IDNA_VARIANT_UTS46);
$footers['plain'] = isset($_data['footer_plain']) ? $_data['footer_plain'] : ''; if (!is_valid_domain_name($domain)) {
try { $_SESSION['return'][] = array(
$redis->hSet('DOMAIN_WIDE_FOOTER', $domain, json_encode($footers)); 'type' => 'danger',
} 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
catch (RedisException $e) { 'msg' => 'domain_invalid'
);
return false;
}
if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $domain)) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => 'access_denied'
);
return false;
}
$footers = array();
$footers['html'] = isset($_data['html']) ? $_data['html'] : '';
$footers['plain'] = isset($_data['plain']) ? $_data['plain'] : '';
$footers['mbox_exclude'] = array();
if (isset($_data["mbox_exclude"])){
if (!is_array($_data["mbox_exclude"])) {
$_data["mbox_exclude"] = array($_data["mbox_exclude"]);
}
foreach ($_data["mbox_exclude"] as $mailbox) {
if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => array('username_invalid', $mailbox)
);
} else {
array_push($footers['mbox_exclude'], $mailbox);
}
}
}
try {
$stmt = $pdo->prepare("DELETE FROM `domain_wide_footer` WHERE `domain`= :domain");
$stmt->execute(array(':domain' => $domain));
$stmt = $pdo->prepare("INSERT INTO `domain_wide_footer` (`domain`, `html`, `plain`, `mbox_exclude`) VALUES (:domain, :html, :plain, :mbox_exclude)");
$stmt->execute(array(
':domain' => $domain,
':html' => $footers['html'],
':plain' => $footers['plain'],
':mbox_exclude' => json_encode($footers['mbox_exclude']),
));
}
catch (PDOException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => $e->getMessage()
);
return false;
}
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'danger', 'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => array('redis_error', $e) 'msg' => array('domain_footer_modified', htmlspecialchars($domain))
); );
return false;
} }
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => array('domain_footer_modified', htmlspecialchars($domain))
);
break; break;
} }
break; break;
@ -3934,13 +4025,17 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) { if (!hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
return false; return false;
} }
$stmt = $pdo->prepare("SELECT `id` FROM `alias` WHERE `address` != `goto` AND `domain` = :domain"); $stmt = $pdo->prepare("SELECT `id`, `address` FROM `alias` WHERE `address` != `goto` AND `domain` = :domain");
$stmt->execute(array( $stmt->execute(array(
':domain' => $_data, ':domain' => $_data,
)); ));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC); $rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while($row = array_shift($rows)) { while($row = array_shift($rows)) {
$aliases[] = $row['id']; if ($_extra == "address"){
$aliases[] = $row['address'];
} else {
$aliases[] = $row['id'];
}
} }
return $aliases; return $aliases;
break; break;
@ -4292,6 +4387,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`mailbox`.`modified`, `mailbox`.`modified`,
`quota2`.`bytes`, `quota2`.`bytes`,
`attributes`, `attributes`,
`custom_attributes`,
`quota2`.`messages` `quota2`.`messages`
FROM `mailbox`, `quota2`, `domain` FROM `mailbox`, `quota2`, `domain`
WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL) WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
@ -4312,6 +4408,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`mailbox`.`modified`, `mailbox`.`modified`,
`quota2replica`.`bytes`, `quota2replica`.`bytes`,
`attributes`, `attributes`,
`custom_attributes`,
`quota2replica`.`messages` `quota2replica`.`messages`
FROM `mailbox`, `quota2replica`, `domain` FROM `mailbox`, `quota2replica`, `domain`
WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL) WHERE (`mailbox`.`kind` = '' OR `mailbox`.`kind` = NULL)
@ -4334,6 +4431,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$mailboxdata['quota'] = $row['quota']; $mailboxdata['quota'] = $row['quota'];
$mailboxdata['messages'] = $row['messages']; $mailboxdata['messages'] = $row['messages'];
$mailboxdata['attributes'] = json_decode($row['attributes'], true); $mailboxdata['attributes'] = json_decode($row['attributes'], true);
$mailboxdata['custom_attributes'] = json_decode($row['custom_attributes'], true);
$mailboxdata['quota_used'] = intval($row['bytes']); $mailboxdata['quota_used'] = intval($row['bytes']);
$mailboxdata['percent_in_use'] = ($row['quota'] == 0) ? '- ' : round((intval($row['bytes']) / intval($row['quota'])) * 100); $mailboxdata['percent_in_use'] = ($row['quota'] == 0) ? '- ' : round((intval($row['bytes']) / intval($row['quota'])) * 100);
$mailboxdata['created'] = $row['created']; $mailboxdata['created'] = $row['created'];
@ -4514,19 +4612,23 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
} }
try { try {
$footers = $redis->hGet('DOMAIN_WIDE_FOOTER', $domain); $stmt = $pdo->prepare("SELECT `html`, `plain`, `mbox_exclude` FROM `domain_wide_footer`
$footers = json_decode($footers, true); WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain
));
$footer = $stmt->fetch(PDO::FETCH_ASSOC);
} }
catch (RedisException $e) { catch (PDOException $e) {
$_SESSION['return'][] = array( $_SESSION['return'][] = array(
'type' => 'danger', 'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr), 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
'msg' => array('redis_error', $e) 'msg' => $e->getMessage()
); );
return false; return false;
} }
return $footers; return $footer;
break; break;
} }
break; break;

View File

@ -3,7 +3,7 @@ function init_db_schema() {
try { try {
global $pdo; global $pdo;
$db_version = "15112023_1536"; $db_version = "21112023_1644";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'"); $stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC)); $num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@ -267,6 +267,20 @@ function init_db_schema() {
), ),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
), ),
"domain_wide_footer" => array(
"cols" => array(
"domain" => "VARCHAR(255) NOT NULL",
"html" => "LONGTEXT",
"plain" => "LONGTEXT",
"mbox_exclude" => "JSON NOT NULL DEFAULT ('[]')",
),
"keys" => array(
"primary" => array(
"" => array("domain")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
"tags_domain" => array( "tags_domain" => array(
"cols" => array( "cols" => array(
"tag_name" => "VARCHAR(255) NOT NULL", "tag_name" => "VARCHAR(255) NOT NULL",
@ -344,6 +358,7 @@ function init_db_schema() {
"local_part" => "VARCHAR(255) NOT NULL", "local_part" => "VARCHAR(255) NOT NULL",
"domain" => "VARCHAR(255) NOT NULL", "domain" => "VARCHAR(255) NOT NULL",
"attributes" => "JSON", "attributes" => "JSON",
"custom_attributes" => "JSON NOT NULL DEFAULT ('{}')",
"kind" => "VARCHAR(100) NOT NULL DEFAULT ''", "kind" => "VARCHAR(100) NOT NULL DEFAULT ''",
"multiple_bookings" => "INT NOT NULL DEFAULT -1", "multiple_bookings" => "INT NOT NULL DEFAULT -1",
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",

View File

@ -199,6 +199,23 @@ jQuery(function($){
}); });
} }
function add_table_row(table_id, type) {
var row = $('<tr />');
if (type == "mbox_attr") {
cols = '<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="attribute" required></td>';
cols += '<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="value" required></td>';
cols += '<td><a href="#" role="button" class="btn btn-sm btn-xs-lg btn-secondary h-100 w-100" type="button">' + lang_admin.remove_row + '</a></td>';
}
row.append(cols);
table_id.append(row);
}
$('#mbox_attr_table').on('click', 'tr a', function (e) {
e.preventDefault();
$(this).parents('tr').remove();
});
$('#add_mbox_attr_row').click(function() {
add_table_row($('#mbox_attr_table'), "mbox_attr");
});
// detect element visibility changes // detect element visibility changes
function onVisible(element, callback) { function onVisible(element, callback) {

View File

@ -1867,8 +1867,6 @@ if (isset($_GET['query'])) {
case "quota_notification_bcc": case "quota_notification_bcc":
process_edit_return(quota_notification_bcc('edit', $attr)); process_edit_return(quota_notification_bcc('edit', $attr));
break; break;
case "domain-wide-footer":
process_edit_return(mailbox('edit', 'domain_wide_footer', $attr));
break; break;
case "mailq": case "mailq":
process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr))); process_edit_return(mailq('edit', array_merge(array('qid' => $items), $attr)));
@ -1881,6 +1879,9 @@ if (isset($_GET['query'])) {
case "template": case "template":
process_edit_return(mailbox('edit', 'mailbox_templates', array_merge(array('ids' => $items), $attr))); process_edit_return(mailbox('edit', 'mailbox_templates', array_merge(array('ids' => $items), $attr)));
break; break;
case "custom-attribute":
process_edit_return(mailbox('edit', 'mailbox_custom_attribute', array_merge(array('mailboxes' => $items), $attr)));
break;
default: default:
process_edit_return(mailbox('edit', 'mailbox', array_merge(array('username' => $items), $attr))); process_edit_return(mailbox('edit', 'mailbox', array_merge(array('username' => $items), $attr)));
break; break;
@ -1900,6 +1901,9 @@ if (isset($_GET['query'])) {
case "template": case "template":
process_edit_return(mailbox('edit', 'domain_templates', array_merge(array('ids' => $items), $attr))); process_edit_return(mailbox('edit', 'domain_templates', array_merge(array('ids' => $items), $attr)));
break; break;
case "footer":
process_edit_return(mailbox('edit', 'domain_wide_footer', array_merge(array('domains' => $items), $attr)));
break;
default: default:
process_edit_return(mailbox('edit', 'domain', array_merge(array('domain' => $items), $attr))); process_edit_return(mailbox('edit', 'domain', array_merge(array('domain' => $items), $attr)));
break; break;

View File

@ -574,6 +574,7 @@
"client_secret": "Client-Secret", "client_secret": "Client-Secret",
"comment_info": "Ein privater Kommentar ist für den Benutzer nicht einsehbar. Ein öffentlicher Kommentar wird als Tooltip im Interface des Benutzers angezeigt.", "comment_info": "Ein privater Kommentar ist für den Benutzer nicht einsehbar. Ein öffentlicher Kommentar wird als Tooltip im Interface des Benutzers angezeigt.",
"created_on": "Erstellt am", "created_on": "Erstellt am",
"custom_attributes": "benutzerdefinierte Attribute",
"delete1": "Lösche Nachricht nach Übertragung vom Quell-Server", "delete1": "Lösche Nachricht nach Übertragung vom Quell-Server",
"delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf Quell-Server vorhanden sind", "delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf Quell-Server vorhanden sind",
"delete2duplicates": "Lösche Duplikate im Ziel", "delete2duplicates": "Lösche Duplikate im Ziel",
@ -614,6 +615,7 @@
"max_quota": "Max. Größe per Mailbox (MiB)", "max_quota": "Max. Größe per Mailbox (MiB)",
"maxage": "Maximales Alter in Tagen einer Nachricht, die kopiert werden soll<br><small>(0 = alle Nachrichten kopieren)</small>", "maxage": "Maximales Alter in Tagen einer Nachricht, die kopiert werden soll<br><small>(0 = alle Nachrichten kopieren)</small>",
"maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)", "maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)",
"mbox_exclude": "Mailboxen ausschließen",
"mbox_rl_info": "Dieses Limit wird auf den SASL Loginnamen angewendet und betrifft daher alle Absenderadressen, die der eingeloggte Benutzer verwendet. Bei Mailbox Ratelimit überwiegt ein Domain-weites Ratelimit.", "mbox_rl_info": "Dieses Limit wird auf den SASL Loginnamen angewendet und betrifft daher alle Absenderadressen, die der eingeloggte Benutzer verwendet. Bei Mailbox Ratelimit überwiegt ein Domain-weites Ratelimit.",
"mins_interval": "Intervall (min)", "mins_interval": "Intervall (min)",
"multiple_bookings": "Mehrfaches Buchen", "multiple_bookings": "Mehrfaches Buchen",
@ -1125,6 +1127,7 @@
"apple_connection_profile_complete": "Dieses Verbindungsprofil beinhaltet neben IMAP- und SMTP-Konfigurationen auch Pfade für die Konfiguration von CalDAV (Kalender) und CardDAV (Adressbücher) für ein Apple-Gerät.", "apple_connection_profile_complete": "Dieses Verbindungsprofil beinhaltet neben IMAP- und SMTP-Konfigurationen auch Pfade für die Konfiguration von CalDAV (Kalender) und CardDAV (Adressbücher) für ein Apple-Gerät.",
"apple_connection_profile_mailonly": "Dieses Verbindungsprofil beinhaltet IMAP- und SMTP-Konfigurationen für ein Apple-Gerät.", "apple_connection_profile_mailonly": "Dieses Verbindungsprofil beinhaltet IMAP- und SMTP-Konfigurationen für ein Apple-Gerät.",
"apple_connection_profile_with_app_password": "Es wird ein neues App-Passwort erzeugt und in das Profil eingefügt, damit bei der Einrichtung kein Passwort eingegeben werden muss. Geben Sie das Profil nicht weiter, da es einen vollständigen Zugriff auf Ihr Postfach ermöglicht.", "apple_connection_profile_with_app_password": "Es wird ein neues App-Passwort erzeugt und in das Profil eingefügt, damit bei der Einrichtung kein Passwort eingegeben werden muss. Geben Sie das Profil nicht weiter, da es einen vollständigen Zugriff auf Ihr Postfach ermöglicht.",
"attribute": "Attribut",
"change_password": "Passwort ändern", "change_password": "Passwort ändern",
"change_password_hint_app_passwords": "Ihre Mailbox hat %d App-Passwörter, die nicht geändert werden. Um diese zu verwalten, gehen Sie bitte zum App-Passwörter-Tab.", "change_password_hint_app_passwords": "Ihre Mailbox hat %d App-Passwörter, die nicht geändert werden. Um diese zu verwalten, gehen Sie bitte zum App-Passwörter-Tab.",
"clear_recent_successful_connections": "Alle erfolgreichen Verbindungen bereinigen", "clear_recent_successful_connections": "Alle erfolgreichen Verbindungen bereinigen",
@ -1244,6 +1247,7 @@
"tls_policy_warning": "<strong>Vorsicht:</strong> Entscheiden Sie sich unverschlüsselte Verbindungen abzulehnen, kann dies dazu führen, dass Kontakte Sie nicht mehr erreichen.<br>Nachrichten, die die Richtlinie nicht erfüllen, werden durch einen Hard-Fail im Mailsystem abgewiesen.<br>Diese Einstellung ist aktiv für die primäre Mailbox, für alle Alias-Adressen, die dieser Mailbox <b>direkt zugeordnet</b> sind (lediglich eine einzige Ziel-Adresse) und der Adressen, die sich aus Alias-Domains ergeben. Ausgeschlossen sind temporäre Aliasse (\"Spam-Alias-Adressen\"), Catch-All Alias-Adressen sowie Alias-Adressen mit mehreren Zielen.", "tls_policy_warning": "<strong>Vorsicht:</strong> Entscheiden Sie sich unverschlüsselte Verbindungen abzulehnen, kann dies dazu führen, dass Kontakte Sie nicht mehr erreichen.<br>Nachrichten, die die Richtlinie nicht erfüllen, werden durch einen Hard-Fail im Mailsystem abgewiesen.<br>Diese Einstellung ist aktiv für die primäre Mailbox, für alle Alias-Adressen, die dieser Mailbox <b>direkt zugeordnet</b> sind (lediglich eine einzige Ziel-Adresse) und der Adressen, die sich aus Alias-Domains ergeben. Ausgeschlossen sind temporäre Aliasse (\"Spam-Alias-Adressen\"), Catch-All Alias-Adressen sowie Alias-Adressen mit mehreren Zielen.",
"user_settings": "Benutzereinstellungen", "user_settings": "Benutzereinstellungen",
"username": "Benutzername", "username": "Benutzername",
"value": "Wert",
"verify": "Verifizieren", "verify": "Verifizieren",
"waiting": "Warte auf Ausführung", "waiting": "Warte auf Ausführung",
"week": "Woche", "week": "Woche",

View File

@ -576,6 +576,7 @@
"client_secret": "Client secret", "client_secret": "Client secret",
"comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview", "comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview",
"created_on": "Created on", "created_on": "Created on",
"custom_attributes": "Custom attributes",
"delete1": "Delete from source when completed", "delete1": "Delete from source when completed",
"delete2": "Delete messages on destination that are not on source", "delete2": "Delete messages on destination that are not on source",
"delete2duplicates": "Delete duplicates on destination", "delete2duplicates": "Delete duplicates on destination",
@ -592,7 +593,8 @@
"from_user": "{= from_user =} - From user part of envelope, e.g for \"moo@mailcow.tld\" it returns \"moo\"", "from_user": "{= from_user =} - From user part of envelope, e.g for \"moo@mailcow.tld\" it returns \"moo\"",
"from_name": "{= from_name =} - From name of envelope, e.g for \"Mailcow &lt;moo@mailcow.tld&gt;\" it returns \"Mailcow\"", "from_name": "{= from_name =} - From name of envelope, e.g for \"Mailcow &lt;moo@mailcow.tld&gt;\" it returns \"Mailcow\"",
"from_addr": "{= from_addr =} - From address part of envelope", "from_addr": "{= from_addr =} - From address part of envelope",
"from_domain": "{= from_domain =} - From domain part of envelope" "from_domain": "{= from_domain =} - From domain part of envelope",
"custom": "{= foo =} - If mailbox has the custom attribute \"foo\" with value \"bar\" it returns \"bar\""
}, },
"domain_footer_plain": "PLAIN footer", "domain_footer_plain": "PLAIN footer",
"domain_quota": "Domain quota", "domain_quota": "Domain quota",
@ -623,6 +625,7 @@
"max_quota": "Max. quota per mailbox (MiB)", "max_quota": "Max. quota per mailbox (MiB)",
"maxage": "Maximum age of messages in days that will be polled from remote<br><small>(0 = ignore age)</small>", "maxage": "Maximum age of messages in days that will be polled from remote<br><small>(0 = ignore age)</small>",
"maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>", "maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
"mbox_exclude": "Exclude mailboxes",
"mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.", "mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.",
"mins_interval": "Interval (min)", "mins_interval": "Interval (min)",
"multiple_bookings": "Multiple bookings", "multiple_bookings": "Multiple bookings",
@ -1141,6 +1144,7 @@
"apple_connection_profile_complete": "This connection profile includes IMAP and SMTP parameters as well as CalDAV (calendars) and CardDAV (contacts) paths for an Apple device.", "apple_connection_profile_complete": "This connection profile includes IMAP and SMTP parameters as well as CalDAV (calendars) and CardDAV (contacts) paths for an Apple device.",
"apple_connection_profile_mailonly": "This connection profile includes IMAP and SMTP configuration parameters for an Apple device.", "apple_connection_profile_mailonly": "This connection profile includes IMAP and SMTP configuration parameters for an Apple device.",
"apple_connection_profile_with_app_password": "A new app password is generated and added to the profile so that no password needs to be entered when setting up your device. Please do not share the file as it grants full access to your mailbox.", "apple_connection_profile_with_app_password": "A new app password is generated and added to the profile so that no password needs to be entered when setting up your device. Please do not share the file as it grants full access to your mailbox.",
"attribute": "Attribute",
"change_password": "Change password", "change_password": "Change password",
"change_password_hint_app_passwords": "Your account has %d app passwords that will not be changed. To manage these, go to the App passwords tab.", "change_password_hint_app_passwords": "Your account has %d app passwords that will not be changed. To manage these, go to the App passwords tab.",
"clear_recent_successful_connections": "Clear seen successful connections", "clear_recent_successful_connections": "Clear seen successful connections",
@ -1271,6 +1275,7 @@
"tls_policy_warning": "<strong>Warning:</strong> If you decide to enforce encrypted mail transfer, you may lose emails.<br>Messages to not satisfy the policy will be bounced with a hard fail by the mail system.<br>This option applies to your primary email address (login name), all addresses derived from alias domains as well as alias addresses <b>with only this single mailbox</b> as target.", "tls_policy_warning": "<strong>Warning:</strong> If you decide to enforce encrypted mail transfer, you may lose emails.<br>Messages to not satisfy the policy will be bounced with a hard fail by the mail system.<br>This option applies to your primary email address (login name), all addresses derived from alias domains as well as alias addresses <b>with only this single mailbox</b> as target.",
"user_settings": "User settings", "user_settings": "User settings",
"username": "Username", "username": "Username",
"value": "Value",
"verify": "Verify", "verify": "Verify",
"waiting": "Waiting", "waiting": "Waiting",
"week": "week", "week": "week",

View File

@ -24,6 +24,7 @@
<script type='text/javascript'> <script type='text/javascript'>
var lang_user = {{ lang_user|raw }}; var lang_user = {{ lang_user|raw }};
var lang_admin = {{ lang_admin|raw }};
var lang_datatables = {{ lang_datatables|raw }}; var lang_datatables = {{ lang_datatables|raw }};
var csrf_token = '{{ csrf_token }}'; var csrf_token = '{{ csrf_token }}';
var pagination_size = Math.trunc('{{ pagination_size }}'); var pagination_size = Math.trunc('{{ pagination_size }}');

View File

@ -285,23 +285,41 @@
{{ lang.edit.domain_footer_info_vars.from_user }} {{ lang.edit.domain_footer_info_vars.from_user }}
{{ lang.edit.domain_footer_info_vars.from_name }} {{ lang.edit.domain_footer_info_vars.from_name }}
{{ lang.edit.domain_footer_info_vars.from_addr }} {{ lang.edit.domain_footer_info_vars.from_addr }}
{{ lang.edit.domain_footer_info_vars.from_domain }}</pre> {{ lang.edit.domain_footer_info_vars.from_domain }}
{{ lang.edit.domain_footer_info_vars.custom }}</pre>
<form class="form-horizontal mt-4" data-id="domain_footer"> <form class="form-horizontal mt-4" data-id="domain_footer">
<div class="row mb-4">
<label class="control-label col-sm-2" for="mbox_exclude">{{ lang.edit.mbox_exclude }}</label>
<div class="col-sm-10">
<select data-live-search="true" data-width="100%" style="width:100%" id="editMboxExclude" name="mbox_exclude" size="10" multiple>
{% for mailbox in mailboxes %}
<option value="{{ mailbox }}" {% if mailbox in domain_footer.mbox_exclude %}selected{% endif %}>
{{ mailbox }}
</option>
{% endfor %}
{% for alias in aliases %}
<option data-subtext="Alias" value="{{ alias }}" {% if alias in domain_footer.mbox_exclude %}selected{% endif %}>
{{ alias }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="row mb-2"> <div class="row mb-2">
<label class="control-label col-sm-2" for="domain_footer_html">{{ lang.edit.domain_footer_html }}:</label> <label class="control-label col-sm-2" for="domain_footer_html">{{ lang.edit.domain_footer_html }}:</label>
<div class="col-sm-10"> <div class="col-sm-10">
<textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_html" name="footer_html">{{ domain_footer.html }}</textarea> <textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_html" name="html">{{ domain_footer.html }}</textarea>
</div> </div>
</div> </div>
<div class="row mb-4"> <div class="row mb-4">
<label class="control-label col-sm-2" for="domain_footer_plain">{{ lang.edit.domain_footer_plain }}:</label> <label class="control-label col-sm-2" for="domain_footer_plain">{{ lang.edit.domain_footer_plain }}:</label>
<div class="col-sm-10"> <div class="col-sm-10">
<textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_plain" name="footer_plain">{{ domain_footer.plain }}</textarea> <textarea spellcheck="false" autocorrect="off" autocapitalize="none" class="form-control" rows="10" id="domain_footer_plain" name="plain">{{ domain_footer.plain }}</textarea>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="offset-sm-2 col-sm-10"> <div class="offset-sm-2 col-sm-10">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="domain_footer" data-item="domain_footer" data-api-url='edit/domain-wide-footer' data-api-attr='{"domain":"{{ domain }}"}' href="#">{{ lang.edit.save }}</button> <button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="domain_footer" data-item="{{ domain }}" data-api-url='edit/domain/footer' data-api-attr='{}' href="#">{{ lang.edit.save }}</button>
</div> </div>
</div> </div>
</form> </form>

View File

@ -5,6 +5,7 @@
<div id="mailbox-content" class="responsive-tabs"> <div id="mailbox-content" class="responsive-tabs">
<ul class="nav nav-tabs" role="tablist"> <ul class="nav nav-tabs" role="tablist">
<li role="presentation" class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#medit">{{ lang.edit.mailbox }}</button></li> <li role="presentation" class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#medit">{{ lang.edit.mailbox }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mattr">{{ lang.edit.custom_attributes }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mpushover">{{ lang.edit.pushover }}</button></li> <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mpushover">{{ lang.edit.pushover }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#macl">{{ lang.edit.acl }}</button></li> <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#macl">{{ lang.edit.acl }}</button></li>
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mrl">{{ lang.edit.ratelimit }}</button></li> <li role="presentation" class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#mrl">{{ lang.edit.ratelimit }}</button></li>
@ -275,6 +276,37 @@
</div> </div>
</div> </div>
</div> </div>
<div id="mattr" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-attr">
<div class="card mb-4">
<div class="card-header d-flex d-md-none fs-5">
<button class="btn flex-grow-1 text-start" data-bs-target="#collapse-tab-mattr" data-bs-toggle="collapse" aria-controls="collapse-tab-mattr">
{{ lang.edit.mailbox }} <span class="badge bg-info table-lines"></span>
</button>
</div>
<div id="collapse-tab-mattr" class="card-body collapse show" data-bs-parent="#mailbox-content">
<form class="form-inline" data-id="mbox_attr" role="form" method="post">
<table class="table table-condensed" style="white-space: nowrap;" id="mbox_attr_table">
<tr>
<th>{{ lang.user.attribute }}</th>
<th>{{ lang.user.value }}</th>
<th style="width:100px;">&nbsp;</th>
</tr>
{% for key, val in result.custom_attributes %}
<tr>
<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="attribute" required value="{{ key }}"></td>
<td><input class="input-sm input-xs-lg form-control" data-id="mbox_attr" type="text" name="value" required value="{{ val }}"></td>
<td><a href="#" role="button" class="btn btn-sm btn-xs-lg btn-secondary h-100 w-100" type="button">{{ lang.admin.remove_row }}</a></td>
</tr>
{% endfor %}
</table>
<p><div class="btn-group">
<button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-success" data-action="edit_selected" data-item="{{ mailbox }}" data-id="mbox_attr" data-api-url='edit/mailbox/custom-attribute' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" type="button" id="add_mbox_attr_row">{{ lang.admin.add_row }}</button>
</div></p>
</form>
</div>
</div>
</div>
<div id="mpushover" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-pushover"> <div id="mpushover" class="tab-pane fade" role="tabpanel" aria-labelledby="mailbox-pushover">
<div class="card mb-4"> <div class="card mb-4">
<div class="card-header d-flex d-md-none fs-5"> <div class="card-header d-flex d-md-none fs-5">