Compare commits

..
Author SHA1 Message Date
FreddleSpl0it 7d27460fee Merge branch 'staging' into feat/imapsync-rework 2026-10-23 11:49:45 +02:00
milkmakerandciri-cuervo 82b1cf7198 [Web] Updated lang.es-es.json (#7542)
Co-authored-by: ciri-cuervo <nicolascirigliano@gmail.com>
2026-10-22 17:54:52 +02:00
58 changed files with 650 additions and 1728 deletions

No files matched your search

+4 -4
View File
@@ -111,16 +111,16 @@ adapt_new_options() {
echo "SQL_PORT=127.0.0.1:13306" >> mailcow.conf
;;
API_KEY)
echo '# Read-write service API key, shown read-only in the web UI' >> mailcow.conf
echo '# Create or override API key for web UI' >> mailcow.conf
echo "#API_KEY=" >> mailcow.conf
;;
API_KEY_READ_ONLY)
echo '# Read-only service API key, shown read-only in the web UI' >> mailcow.conf
echo '# Create or override read-only API key for web UI' >> mailcow.conf
echo "#API_KEY_READ_ONLY=" >> mailcow.conf
;;
API_ALLOW_FROM)
echo '# Must be set for API_KEY and API_KEY_READ_ONLY to be active' >> mailcow.conf
echo '# Comma separated list of IPs/networks' >> mailcow.conf
echo '# Must be set for API_KEY to be active' >> mailcow.conf
echo '# IPs only, no networks (networks can be set via UI)' >> mailcow.conf
echo "#API_ALLOW_FROM=" >> mailcow.conf
;;
SNAT_TO_SOURCE)
@@ -1,5 +1,7 @@
#!/bin/bash
function array_by_comma { local IFS=","; echo "$*"; }
# Wait for containers
while ! mariadb-admin status --ssl=false --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} --silent; do
echo "Waiting for SQL..."
@@ -130,6 +132,33 @@ if [[ "${MASTER}" =~ ^([yY][eE][sS]|[yY])+$ ]]; then
done
fi
# Set API options if env vars are not empty
if [[ ${API_ALLOW_FROM} != "invalid" ]] && [[ ! -z ${API_ALLOW_FROM} ]]; then
IFS=',' read -r -a API_ALLOW_FROM_ARR <<< "${API_ALLOW_FROM}"
declare -a VALIDATED_API_ALLOW_FROM_ARR
REGEX_IP6='^([0-9a-fA-F]{0,4}:){1,7}[0-9a-fA-F]{0,4}(/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))?$'
REGEX_IP4='^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/([0-9]|[1-2][0-9]|3[0-2]))?$'
for IP in "${API_ALLOW_FROM_ARR[@]}"; do
if [[ ${IP} =~ ${REGEX_IP6} ]] || [[ ${IP} =~ ${REGEX_IP4} ]]; then
VALIDATED_API_ALLOW_FROM_ARR+=("${IP}")
fi
done
VALIDATED_IPS=$(array_by_comma ${VALIDATED_API_ALLOW_FROM_ARR[*]})
if [[ ! -z ${VALIDATED_IPS} ]]; then
if [[ ${API_KEY} != "invalid" ]] && [[ ! -z ${API_KEY} ]]; then
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
DELETE FROM api WHERE access = 'rw';
INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY}", "1", "${VALIDATED_IPS}", "rw");
EOF
fi
if [[ ${API_KEY_READ_ONLY} != "invalid" ]] && [[ ! -z ${API_KEY_READ_ONLY} ]]; then
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
DELETE FROM api WHERE access = 'ro';
INSERT INTO api (api_key, active, allow_from, access) VALUES ("${API_KEY_READ_ONLY}", "1", "${VALIDATED_IPS}", "ro");
EOF
fi
fi
fi
# Create events (master only, STATUS for event on slave will be SLAVESIDE_DISABLED)
mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} << EOF
+4
View File
@@ -93,6 +93,10 @@ $template_data = [
'tfa_id' => @$_SESSION['tfa_id'],
'fido2_cid' => @$_SESSION['fido2_cid'],
'fido2_data' => $fido2_data,
'api' => [
'ro' => admin_api('ro', 'get'),
'rw' => admin_api('rw', 'get'),
],
'dkim_domains' => $dkim_domains,
'dkim_domains_with_keys' => $dkim_domains_with_keys,
'dkim_blind_domains' => $dkim_blind_domains,
-284
View File
@@ -15,10 +15,6 @@ info:
- The read only key can only be used for all get endpoints
- The read write key can be used for all endpoints
You can create as many keys of both types as you need. Service keys
defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) are
shown in the UI, but can only be changed in mailcow.conf.
title: mailcow API
version: "1.0.0"
@@ -210,88 +206,6 @@ paths:
type: boolean
type: object
summary: Create time limited alias
/api/v1/add/api-key:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- api
- add
- key
- description: provisioning
access: rw
allow_from: "192.0.2.10, 2001:db8::/64"
skip_ip_check: "0"
active: "1"
msg:
- api_key_added
- provisioning
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:
- API Keys
description: >-
Create an API key with a random value (admin only, not with a read-only
API key). Get the key value with the GET method. Without
`skip_ip_check` at least one IP/network in `allow_from` is required.
operationId: Create API key
requestBody:
content:
application/json:
schema:
example:
description: provisioning
access: rw
allow_from: "192.0.2.10, 2001:db8::/64"
skip_ip_check: "0"
active: "1"
properties:
description:
description: free text, max. 255 characters
type: string
access:
description: "ro (read-only, GET endpoints only) or rw (read-write), required"
enum:
- ro
- rw
type: string
allow_from:
description: >-
IPs/CIDR networks the key may be used from, separated by
commas, spaces or newlines (or an array)
type: string
skip_ip_check:
description: allow the key from any IP
type: boolean
active:
description: is key active or not
type: boolean
type: object
summary: Create API key
/api/v1/add/app-passwd:
post:
responses:
@@ -2120,63 +2034,6 @@ paths:
type: string
type: array
summary: Delete alias
/api/v1/delete/api-key:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- api
- delete
- key
- id:
- "3"
msg:
- api_key_deleted
- provisioning
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:
- API Keys
description: >-
Delete API keys by ID. Service keys from mailcow.conf cannot be
deleted (`api_key_service_locked`).
operationId: Delete API keys
requestBody:
content:
application/json:
schema:
example:
- "3"
properties:
items:
description: contains list of API key IDs you want to delete
type: object
type: object
summary: Delete API keys
/api/v1/delete/app-passwd:
post:
responses:
@@ -3532,94 +3389,6 @@ paths:
type: object
type: object
summary: Update alias
/api/v1/edit/api-key:
post:
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- log:
- api
- edit
- key
- id:
- "3"
regen_key: "1"
msg:
- api_key_modified
- provisioning
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:
- API Keys
description: >-
Update API keys. Attributes you do not send keep their value,
`regen_key` replaces the key value with a new random one. Service keys
from mailcow.conf cannot be changed (`api_key_service_locked`).
operationId: Update API keys
requestBody:
content:
application/json:
schema:
example:
items:
- "3"
attr:
description: provisioning
access: ro
allow_from: "192.0.2.10"
skip_ip_check: "0"
active: "1"
regen_key: "0"
properties:
attr:
properties:
description:
description: free text, max. 255 characters
type: string
access:
enum:
- ro
- rw
type: string
allow_from:
description: IPs/CIDR networks, separated by commas, spaces or newlines (or an array)
type: string
skip_ip_check:
type: boolean
active:
type: boolean
regen_key:
description: issue a new key value
type: boolean
type: object
items:
description: contains list of API key IDs you want to update
type: object
type: object
summary: Update API keys
/api/v1/edit/domain:
post:
responses:
@@ -5058,57 +4827,6 @@ paths:
description: You can list time limited mailbox aliases existing in system.
operationId: Get time limited aliases
summary: Get time limited aliases
"/api/v1/get/api-key/{id}":
get:
parameters:
- description: id of entry you want to get
example: all
in: path
name: id
required: true
schema:
enum:
- all
- "1"
- "2"
type: string
- description: e.g. api-key-string
example: api-key-string
in: header
name: X-API-Key
required: false
schema:
type: string
responses:
"401":
$ref: "#/components/responses/Unauthorized"
"200":
content:
application/json:
examples:
response:
value:
- id: 1
description: API_KEY
access: rw
api_key: 1A2B3C-4D5E6F-7A8B9C-0D1E2F-3A4B5C
allow_from:
- 192.0.2.10
skip_ip_check: 0
service: 1
active: 1
created: "2026-10-22 12:00:00"
modified: null
description: OK
headers: {}
tags:
- API Keys
description: >-
Admin only, not with a read-only API key (`{}` otherwise). Returns all
API keys incl. their values, service keys from mailcow.conf
(`service: 1`) first.
operationId: Get API keys
summary: Get API keys
"/api/v1/get/app-passwd/all/{mailbox}":
get:
parameters:
@@ -7318,8 +7036,6 @@ tags:
description: Force global TLS policys
- name: oAuth Clients
description: Use mailcow as a oAuth server
- name: API Keys
description: Manage API keys
- name: Routing
description: Define your own email routes
- name: Resources
-5
View File
@@ -99,11 +99,6 @@ if (isset($_SESSION['mailcow_cc_role'])) {
$template = 'edit/oauth2client.twig';
$template_data = ['oauth2client' => $oauth2client];
}
elseif (isset($_GET['api-key']) &&
is_numeric($_GET['api-key'])) {
$result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']);
$template = 'edit/api-key.twig';
}
elseif (isset($_GET['aliasdomain']) &&
is_valid_domain_name(html_entity_decode(rawurldecode($_GET["aliasdomain"]))) &&
!empty($_GET["aliasdomain"])) {
-4
View File
@@ -10,10 +10,6 @@ require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'admin') {
exit();
}
// Read-only API keys must not control containers
if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') {
exit();
}
if (preg_match('/^[a-z\-]{0,}-mailcow/', $_GET['service'])) {
if ($_GET['action'] == "start") {
-4
View File
@@ -193,10 +193,6 @@ function acl($_action, $_scope = null, $_data = null, $_extra = null) {
while ($row = array_shift($acl_all)) {
$acl['acl'][$row['Field']] = 1;
}
// Read-only API keys must not log in as other accounts (SOGo SSO, dual login)
if (!empty($_SESSION['mailcow_cc_api']) && ($_SESSION['mailcow_cc_api_access'] ?? '') != 'rw') {
$acl['acl']['login_as'] = 0;
}
}
elseif ($_SESSION['mailcow_cc_role'] == 'domainadmin' ||
(isset($_SESSION["dual-login"]["role"]) && $_SESSION["dual-login"]["role"] == 'domainadmin')) {
+122
View File
@@ -2054,6 +2054,128 @@ function verify_tfa_login($username, $_data) {
return true;
}
}
function admin_api($access, $action, $data = null) {
global $pdo;
if ($_SESSION['mailcow_cc_role'] != "admin") {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__),
'msg' => 'access_denied'
);
return false;
}
if ($access !== "ro" && $access !== "rw") {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__),
'msg' => 'invalid access type'
);
return false;
}
if ($action == "edit") {
$active = (!empty($data['active'])) ? 1 : 0;
$skip_ip_check = (isset($data['skip_ip_check'])) ? 1 : 0;
$allow_from = array();
if (isset($data['allow_from'])) {
$allow_from = array_map('trim', preg_split( "/( |,|;|\n)/", $data['allow_from']));
}
foreach ($allow_from as $key => $val) {
if (empty($val)) {
unset($allow_from[$key]);
continue;
}
if (valid_network($val) !== true) {
$_SESSION['return'][] = array(
'type' => 'warning',
'log' => array(__FUNCTION__, $data),
'msg' => array('ip_invalid', htmlspecialchars($allow_from[$key]))
);
unset($allow_from[$key]);
continue;
}
}
$allow_from = implode(',', array_unique(array_filter($allow_from)));
if (empty($allow_from) && $skip_ip_check == 0) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $data),
'msg' => 'ip_list_empty'
);
return false;
}
$api_key = implode('-', array(
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3)))
));
$stmt = $pdo->query("SELECT `api_key` FROM `api` WHERE `access` = '" . $access . "'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
if (empty($num_results)) {
$stmt = $pdo->prepare("INSERT INTO `api` (`api_key`, `skip_ip_check`, `active`, `allow_from`, `access`)
VALUES (:api_key, :skip_ip_check, :active, :allow_from, :access);");
$stmt->execute(array(
':api_key' => $api_key,
':skip_ip_check' => $skip_ip_check,
':active' => $active,
':allow_from' => $allow_from,
':access' => $access
));
}
else {
if ($skip_ip_check == 0) {
$stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check,
`active` = :active,
`allow_from` = :allow_from
WHERE `access` = :access;");
$stmt->execute(array(
':active' => $active,
':skip_ip_check' => $skip_ip_check,
':allow_from' => $allow_from,
':access' => $access
));
}
else {
$stmt = $pdo->prepare("UPDATE `api` SET `skip_ip_check` = :skip_ip_check,
`active` = :active
WHERE `access` = :access;");
$stmt->execute(array(
':active' => $active,
':skip_ip_check' => $skip_ip_check,
':access' => $access
));
}
}
}
elseif ($action == "regen_key") {
$api_key = implode('-', array(
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3))),
strtoupper(bin2hex(random_bytes(3)))
));
$stmt = $pdo->prepare("UPDATE `api` SET `api_key` = :api_key WHERE `access` = :access");
$stmt->execute(array(
':api_key' => $api_key,
':access' => $access
));
}
elseif ($action == "get") {
$stmt = $pdo->query("SELECT * FROM `api` WHERE `access` = '" . $access . "'");
$apidata = $stmt->fetch(PDO::FETCH_ASSOC);
if ($apidata !== false) {
$apidata['allow_from'] = str_replace(',', PHP_EOL, $apidata['allow_from']);
}
return $apidata;
}
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $data),
'msg' => 'admin_api_modified'
);
}
function license($action, $data = null) {
global $pdo;
global $redis;
+2 -18
View File
@@ -4,7 +4,7 @@ function init_db_schema()
try {
global $pdo;
$db_version = "22102026_1200";
$db_version = "16102026_1200";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -204,25 +204,18 @@ function init_db_schema()
),
"api" => array(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
"api_key" => "VARCHAR(255) NOT NULL",
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
"allow_from" => "VARCHAR(512) NOT NULL",
"skip_ip_check" => "TINYINT(1) NOT NULL DEFAULT '0'",
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
"modified" => "DATETIME ON UPDATE NOW(0)",
"access" => "ENUM('ro', 'rw') NOT NULL DEFAULT 'rw'",
// 1 = defined in mailcow.conf, read-only in UI and API
"service" => "TINYINT(1) NOT NULL DEFAULT '0'",
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
),
"keys" => array(
"primary" => array(
"" => array("id")
"" => array("api_key")
),
"unique" => array(
"api_key" => array("api_key")
)
),
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
),
@@ -1862,13 +1855,4 @@ if (php_sapi_name() == "cli") {
// Dunno
}
init_db_schema();
// Service API keys from mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM)
try {
$api_keys = \Mailcow\ApiKey\ApiKeyModule::create($pdo, \Mailcow\Core\Context::system())->keys;
foreach ($api_keys->syncServiceKeys(getenv()) as $warning) {
echo "API service keys: " . $warning . PHP_EOL;
}
} catch (PDOException $e) {
echo "API service keys: sync failed: " . $e->getMessage() . PHP_EOL;
}
}
@@ -1,40 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey;
use Mailcow\ApiKey\Controller\ApiKeyApiController;
use Mailcow\ApiKey\Repository\ApiKeyRepository;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use PDO;
/**
* Composition root of the API key module.
*
* process_add_return(ApiKeyModule::fromGlobals()->api->addKey($attr));
* ApiKeyModule::create($pdo, Context::system())->keys->syncServiceKeys(getenv()); // php-fpm start
*/
class ApiKeyModule
{
/** Request keys whose values never reach a log */
private const SECRET_KEYS = ['api_key'];
public function __construct(public readonly ApiKeyService $keys, public readonly ApiKeyApiController $api)
{
}
public static function create(PDO $pdo, Context $context): self
{
$keys = new ApiKeyService(new ApiKeyRepository($pdo), $context);
return new self($keys, new ApiKeyApiController($keys, new ApiResponse(self::SECRET_KEYS)));
}
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION; $context overrides the session caller */
public static function fromGlobals(?Context $context = null): self
{
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
}
}
@@ -1,115 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Controller;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
/**
* HTTP API adapter for json_api.php and edit.php (routes `api-key`).
*
* Write methods return what json_api passes to process_*_return() and write their messages via ApiResponse.
* Messages are logged as ['api', action, 'key', data].
*/
class ApiKeyApiController
{
public function __construct(private ApiKeyService $keys, private ApiResponse $response)
{
}
/** POST add/api-key */
public function addKey(array $attr): bool
{
return $this->handle('add', $attr,
fn(array $log): bool => $this->response->success(['api_key_added', self::label($this->keys->create($attr))], $log));
}
/** POST edit/api-key: one message per id */
public function editKeys(?array $items, array $attr): bool
{
$data = array_merge(['id' => $items], $attr);
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
$this->keys->requireAdmin();
return $this->batch((array)($data['id'] ?? []), $log,
fn(int $id): array => ['api_key_modified', self::label($this->keys->update($id, $attr))]);
});
}
/** POST delete/api-key: one message per id */
public function deleteKeys(array $items): bool
{
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
$this->keys->requireAdmin();
return $this->batch($items, $log, fn(int $id): array => ['api_key_deleted', self::label($this->keys->delete($id))]);
});
}
/** GET api-key/all */
public function getKeys(): array
{
return array_map(fn(ApiKey $key): array => $key->toArray(), $this->keys->all());
}
/** GET api-key/<id>, also edit.php */
public function getKey(mixed $id): array
{
$id = $this->toId($id);
$key = ($id === null) ? null : $this->keys->get($id);
return ($key === null) ? [] : $key->toArray();
}
/** Name of a key in messages: its description, else its id */
private static function label(ApiKey $key): string
{
return htmlspecialchars($key->description !== '' ? $key->description : (string)$key->id);
}
/**
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
*/
private function handle(string $action, mixed $data, callable $work): bool
{
$log = ['api', $action, 'key', $data];
try {
$result = $work($log);
} catch (UserError $e) {
$result = $this->response->error($e, $log);
} catch (PDOException $e) {
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
}
$this->response->flush();
return $result;
}
/** One message per id: the success message returned by $work(id), or the UserError */
private function batch(array $ids, array $log, callable $work): bool
{
foreach ($ids as $id) {
try {
$this->response->success($work($this->requireId($id)), $log);
} catch (UserError $e) {
$this->response->error($e, $log);
}
}
return true;
}
/** Integer id from request input ("12", 12); null for anything else */
private function toId(mixed $id): ?int
{
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
}
/** @throws AccessDenied for ids that cannot exist */
private function requireId(mixed $id): int
{
return $this->toId($id) ?? throw new AccessDenied();
}
}
@@ -1,64 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Model;
/**
* An API key (table `api`), sent by clients in the X-API-Key header (see sessions.inc.php).
*
* Unlike other secrets the key is part of toArray(): admins copy it from the UI. ApiKeyService limits
* read access to admins (not with a read-only API key).
*/
class ApiKey
{
public const ACCESS_RO = 'ro';
public const ACCESS_RW = 'rw';
public ?int $id = null;
public string $key = '';
public string $description = '';
public string $access = self::ACCESS_RO;
/** @var string[] IPs and CIDR networks the key may be used from */
public array $allowFrom = [];
public int $skipIpCheck = 0;
/** 1 = service key from mailcow.conf: shown, but neither editable nor deletable in UI and API */
public int $service = 0;
public int $active = 1;
public ?string $created = null;
public ?string $modified = null;
/** Builds a key from an `api` row (column names as in the table) */
public static function fromRow(array $row): self
{
$key = new self();
$key->id = isset($row['id']) ? (int)$row['id'] : null;
$key->key = (string)($row['api_key'] ?? '');
$key->description = (string)($row['description'] ?? '');
$key->access = (string)($row['access'] ?? self::ACCESS_RO);
$key->allowFrom = array_values(array_filter(array_map('trim', explode(',', (string)($row['allow_from'] ?? ''))), 'strlen'));
$key->skipIpCheck = (int)($row['skip_ip_check'] ?? 0);
$key->service = (int)($row['service'] ?? 0);
$key->active = (int)($row['active'] ?? 1);
$key->created = isset($row['created']) ? (string)$row['created'] : null;
$key->modified = isset($row['modified']) ? (string)$row['modified'] : null;
return $key;
}
/** API representation (GET api-key) */
public function toArray(): array
{
return [
'id' => $this->id,
'description' => $this->description,
'access' => $this->access,
'api_key' => $this->key,
'allow_from' => $this->allowFrom,
'skip_ip_check' => $this->skipIpCheck,
'service' => $this->service,
'active' => $this->active,
'created' => $this->created,
'modified' => $this->modified,
];
}
}
@@ -1,84 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Repository;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\Core\Repository;
use PDO;
/**
* SQL for the API keys (table `api`).
*/
class ApiKeyRepository extends Repository
{
/** @return ApiKey[] service keys first, then by id */
public function all(): array
{
return array_map([ApiKey::class, 'fromRow'],
$this->query("SELECT * FROM `api` ORDER BY `service` DESC, `id`")->fetchAll(PDO::FETCH_ASSOC));
}
public function find(int $id): ?ApiKey
{
$row = $this->query("SELECT * FROM `api` WHERE `id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC);
return $row ? ApiKey::fromRow($row) : null;
}
/** Inserts the key and returns the new id */
public function insert(ApiKey $key): int
{
$key->id = $this->insertRow('api', $this->values($key) + ['service' => $key->service]);
return $key->id;
}
public function update(ApiKey $key): void
{
$this->updateRow('api', (int)$key->id, $this->values($key));
}
public function delete(int $id): void
{
$this->query("DELETE FROM `api` WHERE `id` = :id", ['id' => $id]);
}
/**
* Makes the service keys match $keys in one transaction: other service keys are deleted,
* an existing row with the same key (also a UI key) becomes the service key.
*
* @param ApiKey[] $keys
*/
public function syncService(array $keys): void
{
$this->transaction(function () use ($keys): void {
$params = [];
foreach (array_values($keys) as $i => $key) {
$params['k' . $i] = $key->key;
}
$keep = empty($params) ? '' : " AND `api_key` NOT IN (:" . implode(', :', array_keys($params)) . ")";
$this->query("DELETE FROM `api` WHERE `service` = '1'" . $keep, $params);
// No INSERT ... ON DUPLICATE KEY UPDATE: it uses up an id on every php-fpm start
foreach ($keys as $key) {
$id = $this->query("SELECT `id` FROM `api` WHERE `api_key` = :api_key", ['api_key' => $key->key])->fetchColumn();
if ($id === false) {
$this->insertRow('api', $this->values($key) + ['service' => 1]);
} else {
$this->updateRow('api', (int)$id, $this->values($key) + ['service' => 1]);
}
}
});
}
private function values(ApiKey $key): array
{
return [
'api_key' => $key->key,
'description' => $key->description,
'allow_from' => implode(',', $key->allowFrom),
'skip_ip_check' => $key->skipIpCheck,
'access' => $key->access,
'active' => $key->active,
];
}
}
@@ -1,249 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\ApiKey\Service;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Repository\ApiKeyRepository;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
/**
* Use cases and rules for API keys.
*
* Admins only. A read-only API key may not even read the keys, since they include read-write keys.
* Service keys are defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and mirrored
* into the table on every php-fpm start (syncServiceKeys()). UI and API show them, but cannot change them.
*/
class ApiKeyService
{
/** mailcow.conf variable => access of the service key it defines */
public const SERVICE_KEYS = ['API_KEY' => ApiKey::ACCESS_RW, 'API_KEY_READ_ONLY' => ApiKey::ACCESS_RO];
public function __construct(private ApiKeyRepository $repo, private Context $context)
{
}
/** @throws AccessDenied unless an admin (login or read-write API key) */
public function requireAdmin(): void
{
if (!$this->context->isAdmin() || $this->context->isReadOnlyApi()) {
throw new AccessDenied();
}
}
/** @return ApiKey[] all keys, service keys first; [] for other callers */
public function all(): array
{
return $this->canRead() ? $this->repo->all() : [];
}
public function get(int $id): ?ApiKey
{
return $this->canRead() ? $this->repo->find($id) : null;
}
/**
* Creates a key with a random value; `access` is required.
*
* @throws UserError
*/
public function create(array $input): ApiKey
{
$this->requireAdmin();
$key = new ApiKey();
$key->key = self::generate();
$this->apply($key, $input);
$this->repo->insert($key);
return $key;
}
/**
* Updates a key; attributes missing in $input keep their value, `regen_key` issues a new key value.
*
* @throws UserError access_denied, api_key_service_locked, validation errors
*/
public function update(int $id, array $input): ApiKey
{
$this->requireAdmin();
$key = $this->editable($id);
$this->apply($key, $input);
if (self::flag($input['regen_key'] ?? 0) === 1) {
$key->key = self::generate();
}
$this->repo->update($key);
return $key;
}
/**
* Deletes a key and returns it.
*
* @throws UserError access_denied, api_key_service_locked
*/
public function delete(int $id): ApiKey
{
$this->requireAdmin();
$key = $this->editable($id);
$this->repo->delete($id);
return $key;
}
/**
* Mirrors the service keys of mailcow.conf into the table. Runs on php-fpm start (init_db CLI, master only),
* so there is no permission check. $env holds the container environment (getenv()).
* Without a valid API_ALLOW_FROM no service key is active, like before. Service keys that were removed from
* mailcow.conf are deleted; a UI key with the same value becomes the service key.
*
* @return string[] warnings for the container log
*/
public function syncServiceKeys(array $env): array
{
$warnings = [];
$allowFrom = [];
foreach (self::split($env['API_ALLOW_FROM'] ?? '') as $network) {
// 'invalid' is the docker-compose default for an unset variable
if ($network === 'invalid') {
continue;
}
if (self::isNetwork($network)) {
$allowFrom[] = $network;
} else {
$warnings[] = 'API_ALLOW_FROM: skipped invalid IP/network ' . $network;
}
}
$keys = [];
foreach (self::SERVICE_KEYS as $var => $access) {
$value = trim((string)($env[$var] ?? ''));
if ($value === '' || $value === 'invalid') {
continue;
}
if (!preg_match('/^[a-zA-Z0-9-]+$/', $value)) {
$warnings[] = $var . ': invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored';
continue;
}
if (empty($allowFrom)) {
$warnings[] = $var . ': API_ALLOW_FROM contains no valid IP/network, key ignored';
continue;
}
if (isset($keys[$value])) {
$warnings[] = $var . ': same value as ' . $keys[$value]->description . ', key ignored';
continue;
}
$key = new ApiKey();
$key->key = $value;
$key->description = $var;
$key->access = $access;
$key->allowFrom = $allowFrom;
$key->service = 1;
$keys[$value] = $key;
}
$this->repo->syncService(array_values($keys));
return $warnings;
}
private function canRead(): bool
{
return $this->context->isAdmin() && !$this->context->isReadOnlyApi();
}
/** @throws AccessDenied when missing, UserError api_key_service_locked for service keys */
private function editable(int $id): ApiKey
{
$key = $this->repo->find($id) ?? throw new AccessDenied();
if ($key->service === 1) {
throw new UserError('api_key_service_locked', [$key->description]);
}
return $key;
}
/**
* Add/edit rules: attributes missing in $input keep the current value, a new key starts with the
* model defaults. Without skip_ip_check the key needs at least one IP/network.
*
* @throws UserError
*/
private function apply(ApiKey $key, array $input): void
{
if (isset($input['description'])) {
$description = Input::text($input['description']);
if ($description === null || mb_strlen($description) > 255) {
throw new UserError('api_key_description_invalid');
}
$key->description = $description;
}
if (isset($input['access']) || $key->id === null) {
$access = $input['access'] ?? null;
if (!in_array($access, [ApiKey::ACCESS_RO, ApiKey::ACCESS_RW], true)) {
throw new UserError('api_key_access_invalid');
}
$key->access = $access;
}
if (isset($input['allow_from'])) {
$key->allowFrom = self::networks($input['allow_from']);
}
if (isset($input['skip_ip_check'])) {
$key->skipIpCheck = self::flag($input['skip_ip_check']);
}
if (isset($input['active'])) {
$key->active = self::flag($input['active']);
}
if ($key->skipIpCheck === 0 && empty($key->allowFrom)) {
throw new UserError('ip_list_empty');
}
}
/** New random key in the format of the legacy UI: five groups of six hex digits */
private static function generate(): string
{
return implode('-', array_map(fn(): string => strtoupper(bin2hex(random_bytes(3))), range(1, 5)));
}
/** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */
private static function flag(mixed $value): int
{
if (is_array($value)) {
$value = end($value);
}
return filter_var($value, FILTER_VALIDATE_BOOLEAN) ? 1 : 0;
}
/**
* @return string[] validated IPs/networks
* @throws UserError api_key_network_invalid for the first invalid entry
*/
private static function networks(mixed $value): array
{
$networks = self::split($value);
foreach ($networks as $network) {
if (!self::isNetwork($network)) {
throw new UserError('api_key_network_invalid', [htmlspecialchars($network)]);
}
}
return $networks;
}
/** @return string[] unique entries of a list: array or string separated by spaces, commas, semicolons or newlines */
private static function split(mixed $value): array
{
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', is_scalar($value) ? (string)$value : '');
$values = array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values);
return array_values(array_unique(array_filter($values, 'strlen')));
}
/** IPv4/IPv6 address or CIDR network */
private static function isNetwork(string $value): bool
{
[$ip, $prefix] = array_pad(explode('/', $value, 2), 2, null);
if (filter_var($ip, FILTER_VALIDATE_IP) === false) {
return false;
}
if ($prefix === null) {
return true;
}
$max = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) ? 32 : 128;
return ctype_digit($prefix) && (int)$prefix <= $max;
}
}
+4 -17
View File
@@ -14,32 +14,25 @@ namespace Mailcow\Core;
class Context
{
/**
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
* @param ?string $apiAccess 'ro' / 'rw' when the request authenticated with an X-API-Key, else null
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
*/
public function __construct(
public readonly string $role,
public readonly ?string $username,
private readonly array $acl = [],
public readonly bool $accessAll = false,
public readonly ?string $apiAccess = null
public readonly bool $accessAll = false
) {
}
public static function fromSession(array $session): self
{
$username = $session['mailcow_cc_username'] ?? null;
$apiAccess = null;
if (($session['mailcow_cc_api'] ?? false) === true) {
$apiAccess = (($session['mailcow_cc_api_access'] ?? null) === 'rw') ? 'rw' : 'ro';
}
return new self(
(string)($session['mailcow_cc_role'] ?? ''),
is_scalar($username) ? (string)$username : null,
is_array($session['acl'] ?? null) ? $session['acl'] : [],
($session['access_all_exception'] ?? null) == '1',
$apiAccess
($session['access_all_exception'] ?? null) == '1'
);
}
@@ -64,12 +57,6 @@ class Context
return $this->role === 'user';
}
/** Request with a read-only API key (role admin, but no write access) */
public function isReadOnlyApi(): bool
{
return $this->apiAccess === 'ro';
}
public function hasAcl(string $name): bool
{
return isset($this->acl[$name]) && $this->acl[$name] == '1';
-1
View File
@@ -29,7 +29,6 @@ Core/ shared by all modules
```
Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules.
`ApiKey/` (API keys incl. the service keys of mailcow.conf) is a small module with the same layout.
## Layers and rules
-23
View File
@@ -67,9 +67,6 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) {
else {
$_SESSION['mailcow_cc_api_access'] = 'ro';
}
// Bind the session to this key, see the check below
$_SESSION['mailcow_cc_api_key_id'] = $api_return['id'];
$_SESSION['mailcow_cc_api_key_hash'] = hash('sha256', $api_return['api_key']);
}
else {
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for API_USER by " . $_SERVER['REMOTE_ADDR']);
@@ -95,26 +92,6 @@ if (!empty($_SERVER['HTTP_X_API_KEY'])) {
exit();
}
}
elseif (!empty($_SESSION['mailcow_cc_api'])) {
// API session without header: end it when its key was deleted, deactivated, regenerated or its access changed
if (!isset($pdo)) {
// sogo-auth.php (nginx auth_request) loads no prerequisites
$pdo = new PDO($database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name, $database_user, $database_pass, array(
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
));
}
$stmt = $pdo->prepare("SELECT `api_key`, `access` FROM `api` WHERE `id` = :id AND `active` = '1';");
$stmt->execute(array(':id' => intval($_SESSION['mailcow_cc_api_key_id'] ?? 0)));
$api_return = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($api_return['api_key']) ||
!hash_equals((string)($_SESSION['mailcow_cc_api_key_hash'] ?? ''), hash('sha256', $api_return['api_key'])) ||
$api_return['access'] != ($_SESSION['mailcow_cc_api_access'] ?? '')) {
session_unset();
session_destroy();
}
}
// Handle logouts
if (isset($_POST["logout"])) {
+16
View File
@@ -99,6 +99,22 @@ if (isset($_SESSION['mailcow_cc_role']) && $_SESSION['mailcow_cc_role'] == "admi
if (isset($_POST["license_validate_now"])) {
license('verify');
}
if (isset($_POST["admin_api"])) {
if (isset($_POST["admin_api"]["ro"])) {
admin_api('ro', 'edit', $_POST);
}
elseif (isset($_POST["admin_api"]["rw"])) {
admin_api('rw', 'edit', $_POST);
}
}
if (isset($_POST["admin_api_regen_key"])) {
if (isset($_POST["admin_api_regen_key"]["ro"])) {
admin_api('ro', 'regen_key', $_POST);
}
elseif (isset($_POST["admin_api_regen_key"]["rw"])) {
admin_api('rw', 'regen_key', $_POST);
}
}
if (isset($_POST["rspamd_ui"])) {
rspamd_ui('edit', $_POST);
}
+17 -125
View File
@@ -219,93 +219,6 @@ jQuery(function($){
]
});
}
function draw_api_keys() {
// just recalc width if instance already exists
if ($.fn.DataTable.isDataTable('#apikeystable') ) {
$('#apikeystable').DataTable().columns.adjust().responsive.recalc();
return;
}
$('#apikeystable').DataTable({
responsive: true,
processing: true,
serverSide: false,
stateSave: true,
pageLength: pagination_size,
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" +
"tr" +
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
language: lang_datatables,
ajax: {
type: "GET",
url: "/api/v1/get/api-key/all",
dataSrc: function(data){
return process_table_data(data, 'apikeystable');
}
},
createdRow: function(row, data) {
// service keys from mailcow.conf are read-only
if (data.service == 1) $(row).addClass('opacity-75');
},
columns: [
{
// placeholder, so checkbox will not block child row toggle
title: '',
data: null,
searchable: false,
orderable: false,
defaultContent: ''
},
{
title: '',
data: 'chkbox',
searchable: false,
orderable: false,
defaultContent: ''
},
{
title: 'ID',
data: 'id',
defaultContent: ''
},
{
title: lang.description,
data: 'desc',
defaultContent: ''
},
{
title: lang.access,
data: 'access_type',
defaultContent: ''
},
{
title: lang.api_key,
data: 'key',
defaultContent: ''
},
{
title: lang.api_networks,
data: 'networks',
defaultContent: ''
},
{
title: lang.active,
data: 'active',
defaultContent: '',
render: function (data, type) {
if(data == 1) return '<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>';
else return '<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>';
}
},
{
title: lang.action,
data: 'action',
className: 'dt-sm-head-hidden dt-text-right',
defaultContent: ''
},
]
});
}
function draw_admins() {
// just recalc width if instance already exists
if ($.fn.DataTable.isDataTable('#adminstable') ) {
@@ -661,36 +574,6 @@ jQuery(function($){
item.grant_types = 'refresh_token password authorization_code';
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="oauth2_clients" name="multi_select" value="' + item.id + '" />';
});
} else if (table == 'apikeystable') {
$.each(data, function (i, item) {
item.desc = escapeHtml(item.description);
item.access_type = (item.access == 'rw') ?
'<span class="badge bg-warning text-dark">' + lang.api_read_write + '</span>' :
'<span class="badge bg-info">' + lang.api_read_only + '</span>';
item.key = '<code>' + escapeHtml(item.api_key) + '</code> ' +
'<a href="#" class="api-key-copy" data-key="' + escapeHtml(item.api_key) + '" title="' + lang.copy_to_clipboard + '"><i class="bi bi-clipboard"></i></a>';
if (item.skip_ip_check == 1) {
item.networks = '<span class="text-muted">' + lang.api_skip_ip_check + '</span>';
} else {
item.networks = $.map(item.allow_from, function(n) { return escapeHtml(n); }).join('<br>');
}
if (item.service == 1) {
// service keys are shown, but cannot be changed here
item.desc = '<i class="bi bi-lock-fill" title="' + lang.api_service_key + '"></i> ' + item.desc +
' <span class="badge bg-secondary">mailcow.conf</span>';
item.chkbox = '<input type="checkbox" class="form-check-input" disabled />';
item.action = '<div class="btn-group">' +
'<a class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary disabled" aria-disabled="true"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
'<a class="btn btn-xs btn-xs-lg btn-xs-half btn-danger disabled" aria-disabled="true"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
'</div>';
} else {
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="api_keys" name="multi_select" value="' + item.id + '" />';
item.action = '<div class="btn-group">' +
'<a href="/edit/api-key/' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
'<a href="#" data-action="delete_selected" data-id="single-api-key" data-api-url="delete/api-key" data-item="' + encodeURI(item.id) + '" class="btn btn-xs btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
'</div>';
}
});
} else if (table == 'domainadminstable') {
$.each(data, function (i, item) {
item.selected_domains = escapeHtml(item.selected_domains);
@@ -738,7 +621,6 @@ jQuery(function($){
onVisible("[id^=adminstable]", () => draw_admins());
onVisible("[id^=domainadminstable]", () => draw_domain_admins());
onVisible("[id^=oauth2clientstable]", () => draw_oauth2_clients());
onVisible("[id^=apikeystable]", () => draw_api_keys());
onVisible("[id^=forwardinghoststable]", () => draw_fwd_hosts());
onVisible("[id^=relayhoststable]", () => draw_relayhosts());
onVisible("[id^=transportstable]", () => draw_transport_maps());
@@ -748,14 +630,24 @@ jQuery(function($){
event.stopPropagation();
})
// API key: no allowed networks without IP check
$(".api-skip-ip-check").change(function() {
$($(this).data('target')).prop('disabled', this.checked);
// API IP check toggle
$("#skip_ip_check_ro").click(function( event ) {
$("#skip_ip_check_ro").not(this).prop('checked', false);
if ($("#skip_ip_check_ro:checked").length > 0) {
$('#allow_from_ro').prop('disabled', true);
}
else {
$("#allow_from_ro").removeAttr('disabled');
}
});
$(document).on('click', '.api-key-copy', function(e) {
e.preventDefault();
navigator.clipboard.writeText($(this).attr('data-key'));
mailcow_alert_box(lang.copy_to_clipboard, 'success');
$("#skip_ip_check_rw").click(function( event ) {
$("#skip_ip_check_rw").not(this).prop('checked', false);
if ($("#skip_ip_check_rw:checked").length > 0) {
$('#allow_from_rw').prop('disabled', true);
}
else {
$("#allow_from_rw").removeAttr('disabled');
}
});
// Relayhost
$('#testRelayhostModal').on('show.bs.modal', function (e) {
-9
View File
@@ -23,15 +23,6 @@ $(document).ready(function() {
if ($("form[data-id='editalias'] .goto_checkbox:checked").length > 0) {
$('#textarea_alias_goto').prop('disabled', true);
}
// API key
$(".api-skip-ip-check").change(function() {
$($(this).data('target')).prop('disabled', this.checked);
});
$(".api-key-copy").click(function() {
navigator.clipboard.writeText($(this).attr('data-key'));
mailcow_alert_box(lang_admin.copy_to_clipboard, 'success');
});
$(".api-key-regen").click(function() { return confirm(lang_admin.ays); });
$("#mailbox-password-warning-close").click(function( event ) {
$('#mailbox-passwd-hidden-info').addClass('hidden');
-21
View File
@@ -255,9 +255,6 @@ if (isset($_GET['query'])) {
case "oauth2-client":
process_add_return(oauth2('add', 'client', $attr));
break;
case "api-key":
process_add_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->addKey($attr));
break;
case "domain":
switch ($object) {
case "template":
@@ -808,18 +805,6 @@ if (isset($_GET['query'])) {
}
break;
case "api-key":
switch ($object) {
case "all":
process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKeys());
break;
default:
process_get_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($object));
break;
}
break;
case "logs":
switch ($object) {
case "dovecot":
@@ -1723,9 +1708,6 @@ if (isset($_GET['query'])) {
case "oauth2-client":
process_delete_return(oauth2('delete', 'client', array('id' => $items)));
break;
case "api-key":
process_delete_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->deleteKeys($items));
break;
case "app-passwd":
process_delete_return(app_passwd('delete', array('id' => $items)));
break;
@@ -1891,9 +1873,6 @@ if (isset($_GET['query'])) {
case "oauth2-client":
process_edit_return(oauth2('edit', 'client', array_merge(array('id' => $items), $attr)));
break;
case "api-key":
process_edit_return(\Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->editKeys($items, $attr));
break;
case "recipient_map":
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
break;
+2
View File
@@ -99,6 +99,7 @@
"admin": {
"access": "Достъп",
"action": "Действие",
"activate_api": "Активиране на API",
"activate_send": "Активиране на бутона за изпращане",
"active": "Активен",
"active_rspamd_settings_map": "Активна карта с настройки",
@@ -1050,6 +1051,7 @@
"success": {
"acl_saved": "ACL за обект %s е запазен",
"admin_added": "Администраторът %s е добавен",
"admin_api_modified": "Промените в API са запазени",
"admin_modified": "Промените в администратора са запазени",
"admin_removed": "Администраторът %s е премахнат",
"alias_added": "Адресът на псевдонима %s (%d) е добавен",
+1
View File
@@ -93,6 +93,7 @@
"admin": {
"access": "Accés",
"action": "Acció",
"activate_api": "Activar API",
"active": "Actiu",
"add": "Afegir",
"add_domain_admin": "Afegir Administrador del dominio",
+2
View File
@@ -102,6 +102,7 @@
"admin": {
"access": "Přístupy",
"action": "Akce",
"activate_api": "Zapnout API",
"activate_send": "Povolit tlačítko \"Odeslat\"",
"active": "Aktivní",
"active_rspamd_settings_map": "Aktivní nastavení",
@@ -1086,6 +1087,7 @@
"success": {
"acl_saved": "ACL pro objekt %s uloženo",
"admin_added": "Správce %s přidán",
"admin_api_modified": "Změna API uložena",
"admin_modified": "Změny správce uloženy",
"admin_removed": "Správce %s odebrán",
"alias_added": "Byl přidán alias %s (%d)",
+2
View File
@@ -98,6 +98,7 @@
"admin": {
"access": "Adgang",
"action": "Handling",
"activate_api": "Aktivér API",
"activate_send": "Aktivér send knap",
"active": "Aktiv",
"active_rspamd_settings_map": "Kort over aktive indstillinger",
@@ -798,6 +799,7 @@
"success": {
"acl_saved": "ACL til objekt %s gemt",
"admin_added": "Administrator %s er tilføjet",
"admin_api_modified": "Ændringer i API er gemt",
"admin_modified": "Ændringer til administrator er gemt",
"admin_removed": "Administrator %s er blevet fjernet",
"alias_added": "Alias-adresse %s (%d) er tilføjet",
+2 -13
View File
@@ -103,6 +103,7 @@
"admin": {
"access": "Zugang",
"action": "Aktion",
"activate_api": "API aktivieren",
"activate_send": "Senden-Button freischalten",
"active": "Aktiv",
"active_rspamd_settings_map": "Derzeit aktive Settings Map",
@@ -127,11 +128,6 @@
"api_allow_from": "IP-Adressen oder Netzwerke (CIDR Notation) für Zugriff auf API",
"api_info": "Die API befindet sich noch in Entwicklung, die Dokumentation kann unter <a href=\"/api\">/api</a> abgerufen werden.",
"api_key": "API-Key",
"api_key_add": "API-Key hinzufügen",
"api_keys": "API-Keys",
"api_networks": "Erlaubte IPs/Netzwerke",
"api_service_key": "Service-Key aus der mailcow.conf, nur dort änderbar",
"api_service_key_info": "Dieser API-Key ist in der mailcow.conf definiert (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) und kann nur dort geändert werden.",
"api_skip_ip_check": "IP-Check für API nicht ausführen",
"app_hide": "Für Login verstecken",
"app_links": "App-Links",
@@ -150,7 +146,6 @@
"customer_id": "Kunde",
"customize": "UI-Anpassung",
"login_page": "Login-Seite",
"description": "Beschreibung",
"destination": "Ziel",
"dkim_add_key": "ARC/DKIM-Key hinzufügen",
"dkim_domains_selector": "Selector",
@@ -423,10 +418,6 @@
"alias_invalid": "Alias-Adresse %s ist ungültig",
"aliasd_targetd_identical": "Alias-Domain darf nicht gleich Ziel-Domain sein: %s",
"aliases_in_use": "Maximale Anzahl an Aliassen muss größer oder gleich %d sein",
"api_key_access_invalid": "Ungültige API-Zugriffsart, erlaubt sind ro und rw",
"api_key_description_invalid": "Die Beschreibung des API-Keys darf höchstens 255 Zeichen lang sein",
"api_key_network_invalid": "Ungültige IP-Adresse oder ungültiges Netzwerk: %s",
"api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden",
"app_name_empty": "App-Name darf nicht leer sein",
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
@@ -1131,6 +1122,7 @@
"success": {
"acl_saved": "ACL für Objekt %s wurde gesetzt",
"admin_added": "Administrator %s wurde angelegt",
"admin_api_modified": "Änderungen an API wurden gespeichert",
"admin_modified": "Änderungen am Administrator wurden gespeichert",
"admin_removed": "Administrator %s wurde entfernt",
"alias_added": "Alias-Adresse %s (%d) wurde angelegt",
@@ -1139,9 +1131,6 @@
"alias_removed": "Alias-Adresse %s wurde entfernt",
"aliasd_added": "Alias-Domain %s wurde angelegt",
"aliasd_modified": "Änderungen an Alias-Domain %s wurden gespeichert",
"api_key_added": "API-Key %s wurde hinzugefügt",
"api_key_deleted": "API-Key %s wurde entfernt",
"api_key_modified": "Änderungen an API-Key %s wurden gespeichert",
"app_links": "Änderungen an App-Links wurden gespeichert",
"app_passwd_added": "App-Passwort wurde gespeichert",
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
+2 -13
View File
@@ -103,6 +103,7 @@
"admin": {
"access": "Access",
"action": "Action",
"activate_api": "Activate API",
"activate_send": "Activate send button",
"active": "Active",
"active_rspamd_settings_map": "Active settings map",
@@ -129,13 +130,8 @@
"api_allow_from": "Allow API access from these IPs/CIDR network notations",
"api_info": "The API is a work in progress. The documentation can be found at <a href=\"/api\">/api</a>",
"api_key": "API key",
"api_key_add": "Add API key",
"api_keys": "API keys",
"api_networks": "Allowed IPs/networks",
"api_read_only": "Read-Only Access",
"api_read_write": "Read-Write Access",
"api_service_key": "Service key from mailcow.conf, can only be changed there",
"api_service_key_info": "This API key is defined in mailcow.conf (API_KEY, API_KEY_READ_ONLY, API_ALLOW_FROM) and can only be changed there.",
"api_skip_ip_check": "Skip IP check for API",
"app_hide": "Hide for login",
"app_links": "App links",
@@ -156,7 +152,6 @@
"customer_id": "Customer ID",
"customize": "Customize",
"login_page": "Login Page",
"description": "Description",
"destination": "Destination",
"dkim_add_key": "Add ARC/DKIM key",
"dkim_domains_selector": "Selector",
@@ -417,10 +412,6 @@
},
"danger": {
"access_denied": "Access denied or invalid form data",
"api_key_access_invalid": "Invalid API access type, allowed values are ro and rw",
"api_key_description_invalid": "API key description must not exceed 255 characters",
"api_key_network_invalid": "Invalid IP address or network: %s",
"api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there",
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
"alias_domain_invalid": "Alias domain %s is invalid",
"alias_empty": "Alias address must not be empty",
@@ -1138,6 +1129,7 @@
"success": {
"acl_saved": "ACL for object %s saved",
"admin_added": "Administrator %s has been added",
"admin_api_modified": "Changes to API have been saved",
"admin_modified": "Changes to administrator have been saved",
"admin_removed": "Administrator %s has been removed",
"alias_added": "Alias address %s (%d) has been added",
@@ -1146,9 +1138,6 @@
"alias_removed": "Alias %s has been removed",
"aliasd_added": "Added alias domain %s",
"aliasd_modified": "Changes to alias domain %s have been saved",
"api_key_added": "API key %s has been added",
"api_key_deleted": "API key %s has been deleted",
"api_key_modified": "Changes to API key %s have been saved",
"app_links": "Saved changes to app links",
"app_passwd_added": "Added new app password",
"app_passwd_removed": "Removed app password ID %s",
+262 -58
View File
@@ -17,7 +17,7 @@
"spam_policy": "Lista de bloqueo/desbloqueo",
"spam_score": "Puntuación de spam",
"syncjobs": "Trabajos de sincronización",
"tls_policy": "Póliza de TLS",
"tls_policy": "Política de TLS",
"unlimited_quota": "Cuota ilimitada para buzones",
"app_passwds": "Gestionar las contraseñas de aplicaciones",
"domain_desc": "Cambiar descripción del dominio",
@@ -28,7 +28,8 @@
"pw_reset": "Permitir el restablecimiento de la contraseña del usuario mailcow",
"sogo_access": "Permitir la gestión del acceso a SOGo",
"mailbox_relayhost": "Cambiar el host de reenvío para un buzón",
"smtp_ip_access": "Cambiar hosts permitidos para SMTP"
"smtp_ip_access": "Cambiar hosts permitidos para SMTP",
"alias_external_goto": "Permitir alias con dominios de destino externos"
},
"add": {
"activate_filter_warn": "Todos los demás filtros se desactivarán cuando este filtro se active.",
@@ -70,8 +71,8 @@
"relay_all": "Retransmitir todos los destinatarios",
"relay_all_info": "↪ Si se elige <b>no</b> retransmitir todos los destinatarios, será necesario agregar un buzón \"ciego\" por cada destinatario que deba ser retransmitido.",
"relay_domain": "Retransmitir este dominio",
"select": "Por favor selecciona...",
"select_domain": "Por favor elige un dominio primero",
"select": "Seleccione...",
"select_domain": "Elija primero un dominio",
"sieve_desc": "Descripción",
"sieve_type": "Tipo de filtro",
"target_address": "Direcciones destino:",
@@ -93,11 +94,14 @@
"domain_matches_hostname": "El dominio %s coincide con el nombre de host",
"relay_unknown_only": "Reenviar sólo los buzones no existentes. Los buzones existentes se entregarán localmente.",
"relayhost_wrapped_tls_info": "Por favor, no utilice puertos con TLS (habitualmente, el puerto 465). <br>Utilice cualquier puerto no cifrado y emita STARTTLS. Se puede crear una política para imponer TLS en \"TLS policy maps\".",
"tags": "Etiquetas"
"tags": "Etiquetas",
"internal_info": "Los alias internos solo son accesibles desde el propio dominio o sus dominios alias.",
"sender_allowed": "Permitir enviar como este alias"
},
"admin": {
"access": "Acceso",
"action": "Acción",
"activate_api": "Activar API",
"activate_send": "Activar botón de envío",
"active": "Activo",
"active_rspamd_settings_map": "Reglas de ajustes activos",
@@ -155,7 +159,7 @@
"f2b_max_ban_time": "Max tiempo de restricción (s)",
"f2b_netban_ipv4": "Tamaño de subred IPv4 para aplicar la restricción (8-32)",
"f2b_netban_ipv6": "Tamaño de subred IPv6 para aplicar la restricción (8-128)",
"f2b_parameters": "Parametros Fail2ban",
"f2b_parameters": "Parámetros de Fail2ban",
"f2b_retry_window": "Ventana de tiempo entre reintentos",
"f2b_whitelist": "Redes y hosts en lista de desbloqueo",
"filter_table": "Filtrar tabla",
@@ -174,7 +178,7 @@
"include_exclude_info": "Por defecto - sin selección - <b>todos los buzones</b> son notificados.",
"includes": "Incluye a estos destinatarios",
"link": "Enlace",
"loading": "Espera por favor...",
"loading": "Espere, por favor...",
"logo_info": "La imagen se escalará a una altura de 40 px para la barra de navegación superior y un máx. de ancho de 250 px para la página de inicio. Un gráfico escalable es muy recomendable.",
"main_name": "Nombre \"mailcow UI\"",
"merged_vars_hint": "Las filas grises fueron incorporadas de <code>vars.(local.)inc.php</code> y no son modificables.",
@@ -265,7 +269,7 @@
"password_length": "Longitud de la contraseña",
"quicklink_text": "Mostrar u ocultar enlaces rápidos a otras páginas de inicio bajo el formulario de inicio de sesión",
"password_policy_lowerupper": "Debe contener caracteres en minúsculas y mayúsculas",
"rspamd_global_filters_regex": "Sus nombres indican su propósito. Todo el contenido debe constar de expresiones regulares válidas con el formato \"/patrón/opciones\" (por ejemplo, <code>/.+@domain\\.tld/i</code>).<br>\n Si bien se llevan a cabo comprobaciones básicas de cada expresión regular, la funcionalidad de Rspamd puede verse inutilizada, si no consigue interpretar correctamente la sintaxis utilizada.<br>\n Rspamd intentará leer el contenido del mapa cuando éste se modifique. En caso de de problemas, <a href=\"\" data-toggle=\"modal\" data-container=\"rspamd-mailcow\" data-target=\"#RestartContainer\">reinicie Rspamd</a> para forzar una recarga del mapa.<br>Los elementos incluidos en listas de bloqueo se excluyen de la cuarentena.",
"rspamd_global_filters_regex": "Sus nombres indican su propósito. Todo el contenido debe constar de expresiones regulares válidas con el formato \"/patrón/opciones\" (por ejemplo, <code>/.+@domain\\.tld/i</code>).<br>\n Si bien se llevan a cabo comprobaciones básicas de cada expresión regular, la funcionalidad de Rspamd puede verse inutilizada, si no consigue interpretar correctamente la sintaxis utilizada.<br>\n Rspamd intentará leer el contenido del mapa cuando este se modifique. En caso de problemas, <a href=\"\" data-toggle=\"modal\" data-container=\"rspamd-mailcow\" data-target=\"#RestartContainer\">reinicie Rspamd</a> para forzar una recarga del mapa.<br>Los elementos incluidos en listas de bloqueo se excluyen de la cuarentena.",
"iam_use_ssl_info": "Si se habilita SSL y el puerto se establece en el 389, se cambiará automáticamente al 636.",
"iam_login_provisioning": "Crear usuarios automáticamente al iniciar sesión",
"iam_periodic_full_sync": "Sincronización completa periódica",
@@ -384,7 +388,20 @@
"user_quicklink": "Ocultar enlace rápido a página de inicio de sesión de usuario",
"validate_license_now": "Validar el GUID contra el servidor de licencias",
"verify": "Verificar",
"yes": "&#10003;"
"yes": "&#10003;",
"iam_auth_flow_info": "Además del Authorization Code Flow (Standard Flow en Keycloak), que se usa para el inicio de sesión único (SSO), mailcow también admite autenticación con credenciales directas. El Mailpassword Flow intenta validar las credenciales del usuario mediante la API REST de administración de Keycloak. mailcow obtiene la contraseña cifrada del atributo <code>mailcow_password</code>, que está mapeado en Keycloak.",
"iam_extra_permission": "Para que los siguientes ajustes funcionen, el cliente de mailcow en Keycloak necesita una <code>Service account</code> y el permiso <code>view-users</code>.",
"iam_bindpass": "Contraseña de enlace (bind)",
"iam_rest_flow": "Mailpassword Flow",
"iam_binddn": "Bind DN",
"oauth2_info": "La implementación de OAuth2 admite el tipo de concesión \"Authorization Code\" y emite tokens de actualización.<br>\r\nEl servidor también emite automáticamente nuevos tokens de actualización después de usar uno.<br><br>\r\n&#8226; El alcance predeterminado es <i>profile</i>. Solo los usuarios de buzones pueden autenticarse mediante OAuth2. Si se omite el parámetro scope, se usa <i>profile</i>.<br>\r\n&#8226; El cliente debe enviar el parámetro <i>state</i> como parte de la solicitud de autorización.<br><br>\r\nRutas para las solicitudes a la API de OAuth2: <br>\r\n<ul>\r\n <li>Endpoint de autorización: <code>/oauth/authorize</code></li>\r\n <li>Endpoint de tokens: <code>/oauth/token</code></li>\r\n <li>Página de recursos: <code>/oauth/profile</code></li>\r\n</ul>\r\nRegenerar el secreto del cliente no invalida los códigos de autorización existentes, pero estos no podrán renovar su token.<br><br>\r\nRevocar los tokens de los clientes termina de inmediato todas las sesiones activas. Todos los clientes deberán volver a autenticarse.",
"rate_name": "Nombre del límite",
"ui_header_announcement_active": "Activar anuncio",
"ui_theme": "Tema",
"ui_theme_dark": "Incluye una variante en modo oscuro",
"ui_theme_default": "Predeterminado",
"ui_theme_info": "Los temas son carpetas en <code>data/web/css/themes/&lt;name&gt;/</code> con los siguientes archivos:<ul class=\"mb-0\"><li><code>theme.css</code> (obligatorio)</li><li><code>dark.css</code> (opcional, modo oscuro)</li><li><code>bootstrap.css</code> (opcional, reemplaza el tema base de Bootstrap)</li><li><code>theme.json</code> (opcional, <code>name</code> y <code>description</code>)</li></ul>",
"ui_theme_none": "No se encontraron temas."
},
"danger": {
"access_denied": "Acceso denegado o datos del formulario inválidos",
@@ -431,7 +448,7 @@
"map_content_empty": "Contenido no puede estar vacío",
"max_mailbox_exceeded": "Máx. de buzones superado (%d de %d)",
"max_quota_in_use": "Cuota del buzón debe ser mayor o igual a %d MiB",
"mysql_error": "MySQL error: %s",
"mysql_error": "Error de MySQL: %s",
"network_host_invalid": "Red o host inválido: %s",
"next_hop_interferes": "%s interfiere con el siguiente destino %s",
"next_hop_interferes_any": "Siguiente destino existente interfiere con %s",
@@ -446,7 +463,7 @@
"private_key_error": "Error en la llave privada: %s",
"quota_not_0_not_numeric": "Cuota debe ser numérica y >= 0",
"recipient_map_entry_exists": "Una regla de destinatario \"%s\" existe",
"redis_error": "Redis error: %s",
"redis_error": "Error de Redis: %s",
"relayhost_invalid": "relayhost %s es invalido",
"release_send_failed": "El mensaje no pudo ser liberado: %s",
"rl_timeframe": "Marco de tiempo del límite de velocidad esta incorrecto",
@@ -455,14 +472,14 @@
"sender_acl_invalid": "Valor %s del ACL del remitente es inválido",
"set_acl_failed": "Error al establecer el ACL",
"settings_map_invalid": "Ajustes de ID %s invalidos",
"sieve_error": "Sieve parser error: %s",
"sieve_error": "Error del analizador de Sieve: %s",
"spam_learn_error": "Error aprendiendo muestra: %s",
"subject_empty": "Asunto no puede estar vacío",
"target_domain_invalid": "El dominio \"goto\" es inválido",
"targetd_not_found": "Dominio destino no encontrado",
"text_empty": "Texto no puede estar vacío",
"tls_policy_map_entry_exists": "Regla de póliza de TLS \"%s\" existe",
"tls_policy_map_parameter_invalid": "El parámetro de póliza no es válido.",
"tls_policy_map_entry_exists": "La regla de política de TLS \"%s\" ya existe",
"tls_policy_map_parameter_invalid": "El parámetro de la política no es válido.",
"totp_verification_failed": "Verificación TOTP fallida",
"transport_dest_exists": "Destino de la regla de transporte ya existe",
"webauthn_verification_failed": "Verificación WebAuthn fallida: %s",
@@ -525,7 +542,12 @@
"tfa_token_invalid": "Token de autenticación de doble factor no válido",
"to_invalid": "El destinatario no puede quedar en blanco",
"webauthn_authenticator_failed": "No se ha localizado el autenticador seleccionado",
"webauthn_username_failed": "El autenticador seleccionado pertenece a otra cuenta"
"webauthn_username_failed": "El autenticador seleccionado pertenece a otra cuenta",
"tfa_removal_blocked": "No se puede quitar la autenticación de dos factores porque es obligatoria para su cuenta.",
"external_goto_denied": "La dirección de destino externa %s no está permitida",
"max_age_invalid": "La antigüedad máxima %s no es válida",
"quarantine_category_invalid": "La categoría de cuarentena debe ser una de: add_header, reject, all",
"ui_theme_invalid": "Tema no encontrado"
},
"debug": {
"containers_info": "Información de los contenedores",
@@ -535,11 +557,11 @@
"log_info": "<p>Los <b>logs en memoria</b> son recopilados en listas de Redis y recortados a LOG_LINES (%d) cada minuto para prevenir sobrecarga en el sistema.\r\n <br>Los logs en memoria no están destinados a ser persistentes. Todas las aplicaciones que logean a la memoria, también logean en el daemon de Docker y, por lo tanto, en el controlador de registro predeterminado.\r\n El log en memoria se debe utilizar para analizar problemas menores con los contenedores.</p>\r\n <p>Los <b>logs externos</b> se recopilan a través de la API de la aplicación dada.</p>\r\n <p>Los <b>logs estáticos</b> son principalmente registros de actividad, que no están registrados en Dockerd pero que aún deben ser persistentes (excepto los registros de API).</p>",
"logs": "Logs",
"restart_container": "Reiniciar",
"docs": "Docs",
"docs": "Documentación",
"last_modified": "Última modificación",
"size": "Tamaño",
"started_at": "Iniciado el",
"uptime": "Uptime",
"uptime": "Tiempo en funcionamiento",
"static_logs": "Logs estáticos",
"system_containers": "Sistema y Contenedores",
"show_ip": "Mostrar IP pública",
@@ -596,8 +618,8 @@
"full_name": "Nombre completo",
"gal": "Lista global de direcciones (GAL)",
"gal_info": "El GAL contiene todos los objetos de un dominio y no puede ser editado por ningún usuario. Falta información de disponibilidad en SOGo, si está desactivada. <b>Reinicia SOGo para aplicar los cambios.</b>",
"grant_types": "Grant types",
"hostname": "Hostname",
"grant_types": "Tipos de concesión",
"hostname": "Nombre de host",
"inactive": "Inactivo",
"kind": "Tipo",
"last_modified": "Última modificación",
@@ -612,9 +634,9 @@
"password_repeat": "Confirmación de contraseña (repetir):",
"previous": "Página anterior",
"quota_mb": "Cuota (MiB)",
"redirect_uri": "Redirect/Callback URL",
"redirect_uri": "URL de redirección/callback",
"relay_all": "Retransmitir todos los destinatarios",
"relay_all_info": "<small>Si eliges <b>no</b> retransmitir a todos los destinatarios, necesitas agregar un buzón \"ciego\" por cada destinatario que debe ser retransmitido.</small>",
"relay_all_info": "<small>Si elige <b>no</b> retransmitir a todos los destinatarios, deberá agregar un buzón \"ciego\" por cada destinatario que deba ser retransmitido.</small>",
"relay_domain": "Dominio de retransmisión",
"relayhost": "Ruta saliente",
"remove": "Eliminar",
@@ -686,12 +708,36 @@
"spam_alias": "Crear o modificar alias temporales (con caducidad)",
"spam_filter": "Filtro de spam",
"spam_policy": "Añadir o eliminar elementos de la lista de bloqueo/desbloqueo",
"spam_score": "Establecer una puntuación de spam personalizada"
"spam_score": "Establecer una puntuación de spam personalizada",
"domain_footer_info_vars": {
"auth_user": "{= auth_user =} - Nombre de usuario autenticado indicado por un MTA",
"from_user": "{= from_user =} - Parte de usuario del remitente del sobre, p. ej. para \"moo@mailcow.tld\" devuelve \"moo\"",
"from_name": "{= from_name =} - Nombre del remitente del sobre, p. ej. para \"Mailcow &lt;moo@mailcow.tld&gt;\" devuelve \"Mailcow\"",
"from_addr": "{= from_addr =} - Dirección del remitente del sobre",
"from_domain": "{= from_domain =} - Dominio del remitente del sobre",
"custom": "{= foo =} - Si el buzón tiene el atributo personalizado \"foo\" con el valor \"bar\", devuelve \"bar\""
},
"domain_footer_plain": "Pie de página en texto plano",
"internal_info": "Los alias internos solo son accesibles desde el propio dominio o sus dominios alias.",
"sender_allowed": "Permitir enviar como este alias",
"sender_allowed_info": "Si se desactiva, este alias solo puede recibir correo. Use la ACL de remitentes para anularlo y dar permiso de envío a buzones concretos.",
"mta_sts": "MTA-STS",
"mta_sts_info": "<a href='https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security' target='_blank'>MTA-STS</a> es un estándar que obliga a que la entrega de correo entre servidores use TLS con certificados válidos. <br>Se usa cuando <a target='_blank' href='https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities'>DANE</a> no es posible por falta de DNSSEC o porque no es compatible.<br><b>Nota</b>: si el dominio receptor admite DANE con DNSSEC, DANE <b>siempre</b> tiene preferencia; MTA-STS solo actúa como alternativa.",
"mta_sts_version_info": "Define la versión del estándar MTA-STS; actualmente solo es válida <code>STSv1</code>.",
"mta_sts_mode_info": "Hay tres modos para elegir:<ul><li><em>testing</em>: la política solo se supervisa, las infracciones no tienen efecto.</li><li><em>enforce</em>: la política se aplica estrictamente y se rechazan las conexiones sin TLS válido.</li><li><em>none</em>: la política se publica pero no se aplica.</li></ul>",
"mta_sts_max_age": "Antigüedad máxima",
"mta_sts_max_age_info": "Tiempo en segundos durante el cual los servidores receptores pueden guardar esta política en caché antes de volver a obtenerla.",
"mta_sts_mx_info": "Permite enviar solo a los nombres de servidor de correo indicados explícitamente; el MTA emisor comprueba que el nombre del registro MX coincida con la lista de la política y solo entrega con un certificado TLS válido (protege contra ataques MITM).",
"mta_sts_mx_notice": "Se pueden indicar varios servidores MX (separados por comas).",
"mta_sts_active_info": "Si se desmarca, la política MTA-STS no se publicará y no se solicitará ningún certificado para el subdominio mta-sts mediante ACME.",
"pushover_only_x_prio": "Considerar solo el correo de alta prioridad [<code>X-Priority: 1</code>]",
"pushover_sender_regex": "Considerar la siguiente expresión regular de remitente",
"pushover_vars": "Si no se define ningún filtro de remitente, se considerarán todos los correos.<br>Los filtros por expresión regular y las comprobaciones exactas de remitente pueden definirse por separado y se evalúan en orden. No dependen entre sí.<br>Variables utilizables para el texto y el título (tenga en cuenta las normas de protección de datos)"
},
"footer": {
"hibp_nok": "¡Se encontró coincidencia - esta es una contraseña <b>no segura</b>, selecciona otra!",
"hibp_nok": "¡Se encontró una coincidencia: esta es una contraseña <b>no segura</b>, elija otra!",
"hibp_ok": "No se encontraron coincidencias",
"loading": "Espera por favor...",
"loading": "Espere, por favor...",
"restart_now": "Reiniciar ahora",
"restart_container": "Reiniciar contenedor",
"restart_container_info": "<b>Importante:</b> Un reinicio limpio puede llevar un tiempo. Por favor, espere a que finalice.",
@@ -706,7 +752,7 @@
"header": {
"administration": "Administración",
"debug": "Información",
"email": "E-Mail",
"email": "Correo electrónico",
"mailcow_config": "Configuración",
"quarantine": "Cuarentena",
"restart_sogo": "Reiniciar SOGo",
@@ -752,12 +798,12 @@
"add_bcc_entry": "Añadir regla de BCC",
"add_domain": "Agregar dominio",
"add_domain_alias": "Agregar alias de dominio",
"add_domain_record_first": "Agrega un dominio primero",
"add_domain_record_first": "Agregue primero un dominio",
"add_filter": "Añadir filtro",
"add_mailbox": "Agregar buzón",
"add_recipient_map_entry": "Añadir regla de destinatario",
"add_resource": "Añadir recurso",
"add_tls_policy_map": "Añadir regla de póliza de TLS",
"add_tls_policy_map": "Agregar regla de política de TLS",
"address_rewriting": "Reescritura de direcciones",
"alias": "Alias",
"aliases": "Alias",
@@ -835,20 +881,20 @@
"running": "En marcha",
"set_postfilter": "Marcar como posfiltro",
"set_prefilter": "Marcar como prefiltro",
"sieve_info": "Puedes almacenar múltiples filtros por usuario, pero solo un prefiltro y un postfiltro pueden estar activos al mismo tiempo.<br>\r\nCada filtro será procesado en el orden descrito. Ni un script fallido ni un \"keep;\" emitido detendrá el procesamiento de otros scripts.<br>\r\n<a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_before\" target=\"_blank\">Prefiltro sieve global</a> → Prefiltro → Scripts del usuario → Posfiltro → <a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_after\" target=\"_blank\">Posfiltro sieve global</a>",
"status": "Status",
"sieve_info": "Puede almacenar varios filtros por usuario, pero solo un prefiltro y un postfiltro pueden estar activos al mismo tiempo.<br>\r\nCada filtro será procesado en el orden descrito. Ni un script fallido ni un \"keep;\" emitido detendrá el procesamiento de otros scripts.<br>\r\n<a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_before\" target=\"_blank\">Prefiltro sieve global</a> → Prefiltro → Scripts del usuario → Posfiltro → <a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_after\" target=\"_blank\">Posfiltro sieve global</a>",
"status": "Estado",
"target_address": "Dirección destino",
"target_domain": "Dominio destino",
"tls_enforce_in": "Forzar TLS entrance",
"tls_enforce_out": "Forzar TLS saliente",
"tls_map_dest": "Destino",
"tls_map_dest_info": "Ejemplos: example.org, .example.org, [mail.example.org]:25",
"tls_map_parameters": "Parametros",
"tls_map_parameters_info": "Vacío o parametros, por ejemplo: protocols=!SSLv2 ciphers=medium exclude=3DES",
"tls_map_policy": "Póliza",
"tls_map_parameters": "Parámetros",
"tls_map_parameters_info": "Vacío o parámetros, por ejemplo: protocols=!SSLv2 ciphers=medium exclude=3DES",
"tls_map_policy": "Política",
"tls_policy_maps": "Políticas de TLS",
"tls_policy_maps_info": "Esta póliza anula las reglas de transporte TLS salientes independientemente de la configuración de la política TLS de los usuarios.<br>\r\n Revisa <a href=\"http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps\" target=\"_blank\">the \"smtp_tls_policy_maps\" docs</a> para mas información.",
"tls_policy_maps_long": "Forzar póliza de TLS saliente",
"tls_policy_maps_info": "Este mapa de políticas anula las reglas de transporte TLS salientes, independientemente de la configuración de la política TLS de los usuarios.<br>\r\n Consulte <a href=\"http://www.postfix.org/postconf.5.html#smtp_tls_policy_maps\" target=\"_blank\">la documentación de \"smtp_tls_policy_maps\"</a> para más información.",
"tls_policy_maps_long": "Forzar política de TLS saliente",
"toggle_all": "Selecionar todo",
"username": "Nombre de usuario",
"waiting": "Esperando",
@@ -903,7 +949,12 @@
"table_size_show_n": "Mostrar %s elementos",
"templates": "Plantillas",
"template": "Plantilla",
"yes": "&#10003;"
"yes": "&#10003;",
"add_alias_expand": "Extender el alias a los dominios alias",
"alias_domain_backupmx": "Dominio alias inactivo para el dominio de retransmisión",
"catch_all": "Recoge-todo (catch-all)",
"force_tfa": "2FA",
"last_run_reset": "Programar la siguiente"
},
"oauth2": {
"access_denied": "Inicie sesión como propietario del buzón para otorgar acceso a través de OAuth2.",
@@ -928,8 +979,8 @@
"medium_danger": "Peligro medio",
"neutral_danger": "Neutral",
"qhandler_success": "Solicitud enviada con éxito al sistema, puede cerrar la ventana",
"qid": "Rspamd QID",
"qitem": "Quarantine item",
"qid": "QID de Rspamd",
"qitem": "Elemento en cuarentena",
"quarantine": "Cuarentena",
"quick_actions": "Acciones",
"rcpt": "Destinatario",
@@ -951,14 +1002,29 @@
"download_eml": "Descargar (.eml)",
"info": "Información",
"junk_folder": "Carpeta de correo no deseado",
"notified": "Notificado"
"notified": "Notificado",
"qinfo": "El sistema de cuarentena guarda en la base de datos el correo rechazado (al remitente <em>no</em> se le dará la impresión de que el correo fue entregado) y también el correo que se entrega como copia en la carpeta de correo no deseado de un buzón.\r\n <br>\"Aprender como spam y eliminar\" aprende un mensaje como spam mediante el teorema de Bayes y calcula hashes difusos para rechazar mensajes similares en el futuro.\r\n <br>Tenga en cuenta que aprender varios mensajes puede llevar tiempo, según su sistema.<br>Los elementos de la lista de bloqueo se excluyen de la cuarentena.",
"quick_delete_link": "Abrir enlace de eliminación rápida",
"quick_release_link": "Abrir enlace de liberación rápida",
"rewrite_subject": "Reescribir asunto",
"sender_header": "Remitente (encabezado \"From\")",
"settings_info": "Cantidad máxima de elementos en cuarentena (por buzón): %s<br>Tamaño máximo de correo: %s MiB"
},
"queue": {
"queue_manager": "Administrador de cola"
"queue_manager": "Administrador de cola",
"flush": "Vaciar la cola",
"info": "La cola de correo contiene todos los correos que esperan ser entregados. Si un correo permanece mucho tiempo en la cola, el sistema lo elimina automáticamente.<br>El mensaje de error de cada correo indica por qué no se pudo entregar.",
"legend": "Acciones de la cola de correo:",
"deliver_mail_legend": "Intenta volver a entregar los correos seleccionados.",
"hold_mail_legend": "Retiene los correos seleccionados. (Impide nuevos intentos de entrega)",
"show_message": "Mostrar mensaje",
"unban": "desbloquear cola",
"unhold_mail": "Liberar",
"unhold_mail_legend": "Libera los correos seleccionados para su entrega. (Requiere que hayan sido retenidos antes)"
},
"start": {
"help": "Mostrar/Ocultar panel de ayuda",
"imap_smtp_server_auth_info": "Por favor utiliza tu dirección de correo completa y el mecanismo de autenticación PLAIN.<br>\r\nTus datos para iniciar sesión serán cifrados por el cifrado obligatorio del servidor"
"imap_smtp_server_auth_info": "Utilice su dirección de correo completa y el mecanismo de autenticación PLAIN.<br>\r\nSus datos de inicio de sesión se cifran gracias al cifrado obligatorio del servidor"
},
"success": {
"acl_saved": "ACL para el objeto %s guardado",
@@ -985,8 +1051,8 @@
"domain_admin_removed": "Administrador del dominio %s removido",
"domain_modified": "Cambios al dominio %s guardados",
"domain_removed": "Dominio %s removido",
"forwarding_host_added": "Forwarding host %s has been added",
"forwarding_host_removed": "Forwarding host %s has been removed",
"forwarding_host_added": "Se agregó el host de reenvío %s",
"forwarding_host_removed": "Se eliminó el host de reenvío %s",
"logged_in_as": "Conectado como %s",
"mailbox_added": "Buzón %s agregado",
"mailbox_modified": "Cambios al buzón %s guardados",
@@ -1004,31 +1070,86 @@
"saved_settings": "Ajustes guardados",
"settings_map_added": "Ajustes agregados",
"settings_map_removed": "Ajustes del ID %s removidos",
"tls_policy_map_entry_deleted": "Regla de póliza de TLS con ID %s ha sido elimindada",
"tls_policy_map_entry_saved": "Regla de póliza de TLS \"%s\" ha sido guardada",
"tls_policy_map_entry_deleted": "Se eliminó la regla de política de TLS con ID %s",
"tls_policy_map_entry_saved": "Se guardó la regla de política de TLS \"%s\"",
"verified_totp_login": "Inicio de sesión TOTP verificado",
"verified_webauthn_login": "Inicio de sesión WebAuthn verificado",
"verified_yotp_login": "Inicio de sesión Yubico OTP verificado"
"verified_yotp_login": "Inicio de sesión Yubico OTP verificado",
"admin_added": "Se agregó el administrador %s",
"admin_api_modified": "Se guardaron los cambios en la API",
"admin_removed": "Se eliminó el administrador %s",
"app_links": "Se guardaron los cambios en los enlaces de aplicaciones",
"app_passwd_added": "Se agregó una nueva contraseña de aplicación",
"app_passwd_removed": "Se eliminó la contraseña de aplicación con ID %s",
"cors_headers_edited": "Se guardó la configuración de CORS",
"custom_login_modified": "La personalización del inicio de sesión se guardó correctamente",
"domain_add_dkim_available": "Ya existía una clave DKIM",
"dkim_duplicated": "La clave DKIM del dominio %s se copió a %s",
"domain_footer_modified": "Se guardaron los cambios en el pie de página del dominio %s",
"dovecot_restart_success": "Dovecot se reinició correctamente",
"eas_reset": "Se restablecieron los dispositivos ActiveSync del usuario %s",
"f2b_banlist_refreshed": "El ID de la lista de bloqueo se actualizó correctamente.",
"f2b_modified": "Se guardaron los cambios en los parámetros de Fail2ban",
"global_filter_written": "El filtro se escribió correctamente en el archivo",
"hash_deleted": "Hash eliminado",
"iam_test_connection": "Conexión correcta",
"ip_check_opt_in_modified": "La comprobación de IP se guardó correctamente",
"item_deleted": "Elemento %s eliminado correctamente",
"item_released": "Elemento %s liberado",
"items_deleted": "Elemento %s eliminado correctamente",
"items_released": "Se liberaron los elementos seleccionados",
"learned_ham": "El ID %s se aprendió correctamente como correo legítimo (ham)",
"license_modified": "Se guardaron los cambios en la licencia",
"mailbox_renamed": "El buzón se renombró de %s a %s",
"nginx_reloaded": "Nginx se recargó",
"object_modified": "Se guardaron los cambios en el objeto %s",
"password_policy_saved": "La política de contraseñas se guardó correctamente",
"password_changed_success": "La contraseña se cambió correctamente",
"pushover_settings_edited": "La configuración de Pushover se guardó; verifique las credenciales.",
"recovery_email_sent": "Se envió el correo de recuperación a %s",
"reset_main_logo": "Se restableció el logo predeterminado",
"resource_removed": "Se eliminó el recurso %s",
"sogo_profile_reset": "Se restableció el perfil de SOGo del usuario %s",
"template_added": "Se agregó la plantilla %s",
"template_modified": "Se guardaron los cambios en la plantilla %s",
"template_removed": "Se eliminó la plantilla con ID %s",
"ui_texts": "Se guardaron los cambios en los textos de la interfaz",
"ui_theme_modified": "El tema se guardó correctamente",
"upload_success": "Archivo subido correctamente",
"verified_fido2_login": "Inicio de sesión FIDO2 verificado"
},
"tfa": {
"api_register": "%s utiliza la API de la nube de Yubico. Por favor, obtén una clave API para tu llave <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">aquí</a>.",
"api_register": "%s utiliza la API en la nube de Yubico. Obtenga una clave de API para su llave <a href=\"https://upgrade.yubico.com/getapikey/\" target=\"_blank\">aquí</a>.",
"confirm": "Confirmar",
"confirm_totp_token": "Por favor confirma los cambios ingresando el token generado",
"confirm_totp_token": "Confirme los cambios ingresando el token generado",
"delete_tfa": "Desactivar TFA",
"disable_tfa": "Desactivar TFA hasta el próximo inicio de sesión exitoso",
"enter_qr_code": "Su código TOTP si su dispositivo no puede escanear códigos QR",
"key_id": "Un identificador para tu YubiKey",
"key_id_totp": "Un identificador para tu llave",
"key_id": "Un identificador para su YubiKey",
"key_id_totp": "Un identificador para su llave",
"none": "Desactivar",
"scan_qr_code": "Escanee el siguiente código con su aplicación de autenticador o ingrese el código manualmente.",
"select": "Selecciona",
"select": "Seleccione",
"set_tfa": "Establecer el método de autenticación de dos factores",
"tfa": "Autenticación de dos factores",
"totp": "OTP basado en tiempo (Google Authenticator, Authy, etc.)",
"webauthn": "Autenticación WebAuthn",
"waiting_usb_auth": "<i>Esperando al dispositivo USB...</i><br><br>Toque el botón en su dispositivo USB WebAuthn ahora.",
"waiting_usb_register": "<i>Esperando al dispositivo USB....</i><br><br>Ingrese su contraseña arriba y confirme su registro WebAuthn tocando el botón en su dispositivo USB WebAuthn.",
"yubi_otp": "Yubico OTP"
"yubi_otp": "Yubico OTP",
"authenticators": "Autenticadores",
"force_tfa": "Exigir configurar 2FA al iniciar sesión",
"force_tfa_info": "El usuario deberá configurar la autenticación de dos factores antes de acceder al panel.",
"setup_title": "Autenticación de dos factores obligatoria",
"setup_required": "Su cuenta requiere autenticación de dos factores. Configure un método de 2FA para continuar.",
"cancel_setup": "Cancelar y cerrar sesión",
"error_code": "Código de error",
"init_webauthn": "Inicializando, espere...",
"reload_retry": "- (recargue el navegador si el error persiste)",
"start_webauthn_validation": "Iniciar validación",
"tfa_token_invalid": "Token de 2FA no válido",
"u2f_deprecated": "Parece que su llave se registró con el método obsoleto U2F. Desactivaremos la autenticación de dos factores y eliminaremos su llave.",
"u2f_deprecated_important": "Registre su llave en el panel de administración con el nuevo método WebAuthn."
},
"syncjobs": {
"sync_jobs": "Trabajos de sincronización",
@@ -1067,7 +1188,7 @@
"alias": "Alias",
"alias_create_random": "Generar alias aleatorio",
"alias_extend_all": "Extender alias por 1 hora",
"alias_full_date": "d.m.Y, H:i:s T",
"alias_full_date": "d/m/Y, H:i:s T",
"alias_remove_all": "Eliminar todos los alias",
"alias_select_validity": "Periodo de validez",
"alias_time_left": "Tiempo restante",
@@ -1117,7 +1238,7 @@
"spamfilter_bl": "Lista negra",
"spamfilter_bl_desc": "Direcciones en la lista negra <b>siempre</b> clasificarán como spam. Se puede usar comodines. Filtro solo aplica a los alias directos (alias con un solo buzón de destino) excluyendo los alias catch-all.",
"spamfilter_default_score": "Valores predeterminados:",
"spamfilter_green": "Verde: éste mensaje no es spam",
"spamfilter_green": "Verde: este mensaje no es spam",
"spamfilter_hint": "El primer valor representa la \"calificación baja de spam\", el segundo representa la \"calificación alta de spam\".",
"spamfilter_red": "Rojo: Este mensaje es spam, será rechazado por el servidor y enviado a la quarantena (si esta configurada)",
"spamfilter_table_action": "Acción",
@@ -1127,8 +1248,8 @@
"spamfilter_table_rule": "Regla",
"spamfilter_wl": "Lista blanca",
"spamfilter_wl_desc": "Direcciones en la lista blanca <b>nunca</b> clasificarán como spam. Se puede usar comodines. Filtro solo aplica a los alias directos (alias con un solo buzón de destino) excluyendo los alias catch-all.",
"spamfilter_yellow": "Amarillo: éste mensaje puede ser spam, será etiquetado como spam y trasladado a tu carpeta de correo no deseado",
"status": "Status",
"spamfilter_yellow": "Amarillo: este mensaje puede ser spam, será etiquetado como spam y movido a su carpeta de correo no deseado",
"status": "Estado",
"tag_handling": "Establecer manejo para el correo etiquetado",
"tag_help_example": "Ejemplo de una dirección email etiquetada: mi<b>+Facebook</b>@ejemplo.org",
"tag_help_explain": "En subcarpeta: una nueva subcarpeta llamada como la etiqueta será creada debajo de INBOX (\"INBOX/Facebook\").<br>\r\nEn asunto: los nombres de las etiquetas serán añadidos al asunto de los correos, ejemplo: \"[Facebook] Mis Noticias\".",
@@ -1147,13 +1268,89 @@
"weeks": "Semanas",
"with_app_password": "con contraseña de aplicación",
"year": "año",
"years": "años"
"years": "años",
"active_sieve": "Filtro activo",
"advanced_settings": "Configuración avanzada",
"app_hint": "Las contraseñas de aplicación son contraseñas alternativas para iniciar sesión en IMAP, SMTP, CalDAV, CardDAV y EAS. El nombre de usuario no cambia. El webmail SOGo no está disponible con contraseñas de aplicación.",
"allowed_protocols": "Protocolos permitidos",
"app_name": "Nombre de la aplicación",
"app_passwds": "Contraseñas de aplicación",
"apple_connection_profile": "Perfil de conexión de Apple",
"apple_connection_profile_complete": "Este perfil de conexión incluye los parámetros de IMAP y SMTP, y las rutas de CalDAV (calendarios) y CardDAV (contactos) para un dispositivo Apple.",
"apple_connection_profile_mailonly": "Este perfil de conexión incluye los parámetros de configuración de IMAP y SMTP para un dispositivo Apple.",
"apple_connection_profile_with_app_password": "Se genera una nueva contraseña de aplicación y se agrega al perfil, para que no haga falta escribir ninguna contraseña al configurar el dispositivo. No comparta el archivo: da acceso completo a su buzón.",
"attribute": "Atributo",
"authentication": "Autenticación",
"change_password_hint_app_passwords": "Su cuenta tiene %d contraseñas de aplicación que no se cambiarán. Para administrarlas, vaya a la pestaña Contraseñas de aplicación.",
"clear_recent_successful_connections": "Borrar las conexiones correctas registradas",
"client_configuration": "Mostrar guías de configuración para clientes de correo y teléfonos",
"create_app_passwd": "Crear contraseña de aplicación",
"delete_ays": "Confirme la eliminación.",
"direct_protocol_access": "Este usuario de buzón tiene <b>acceso directo y externo</b> a los siguientes protocolos y aplicaciones. Su administrador controla este ajuste. Puede crear contraseñas de aplicación para dar acceso a protocolos y aplicaciones concretos.<br>El botón \"Webmail\" ofrece inicio de sesión único en SOGo y siempre está disponible.",
"email": "Correo electrónico",
"email_and_dav": "Correo, calendarios y contactos",
"empty": "Sin resultados",
"fido2_webauthn": "FIDO2/WebAuthn",
"force_pw_update": "<b>Debe</b> establecer una nueva contraseña para acceder a los servicios de groupware.",
"from": "de",
"generate": "generar",
"in_use": "En uso",
"last_mail_login": "Último inicio de sesión de correo",
"last_pw_change": "Último cambio de contraseña",
"last_ui_login": "Último inicio de sesión en la interfaz",
"loading": "Cargando...",
"login_history": "Historial de inicios de sesión",
"mailbox": "Buzón",
"mailbox_general": "General",
"mailbox_settings": "Configuración",
"messages": "mensajes",
"month": "mes",
"months": "meses",
"no_active_filter": "No hay ningún filtro activo",
"no_last_login": "No hay información del último inicio de sesión en la interfaz",
"open_logs": "Abrir registros",
"open_webmail_sso": "Webmail",
"overview": "Resumen",
"password": "Contraseña",
"pw_update_required": "Su cuenta requiere un cambio de contraseña. Establezca una nueva contraseña para continuar.",
"password_repeat": "Contraseña (repetir)",
"password_reset_info": "Si no se indica un correo de recuperación de contraseña, esta función no se puede usar.",
"protocols": "Protocolos",
"pushover_evaluate_x_prio": "Escalar el correo de alta prioridad [<code>X-Priority: 1</code>]",
"pushover_info": "La configuración de notificaciones push se aplicará a todo el correo limpio (no spam) entregado a <b>%s</b>, incluidos los alias (compartidos, no compartidos, etiquetados).",
"pushover_only_x_prio": "Considerar solo el correo de alta prioridad [<code>X-Priority: 1</code>]",
"pushover_sender_array": "Considerar las siguientes direcciones de remitente <small>(separadas por comas)</small>",
"pushover_sender_regex": "Filtrar remitentes con la siguiente expresión regular",
"pushover_text": "Texto de la notificación",
"pushover_title": "Título de la notificación",
"pushover_sound": "Sonido",
"pushover_vars": "Si no se define ningún filtro de remitente, se considerarán todos los correos.<br>Los filtros por expresión regular y las comprobaciones exactas de remitente pueden definirse por separado y se evalúan en orden. No dependen entre sí.<br>Variables utilizables para el texto y el título (tenga en cuenta las normas de protección de datos)",
"pushover_verify": "Verificar credenciales",
"pw_recovery_email": "Correo de recuperación de contraseña",
"q_add_header": "Carpeta de correo no deseado",
"quarantine_category_info": "La categoría de notificación \"Rechazado\" incluye el correo que fue rechazado, mientras que \"Carpeta de correo no deseado\" avisa al usuario de los correos que se movieron a esa carpeta.",
"recent_successful_connections": "Conexiones correctas registradas",
"sender_acl_disabled": "<span class=\"badge fs-6 bg-danger\">La comprobación de remitente está desactivada</span>",
"show_sieve_filters": "Mostrar el filtro Sieve activo del usuario",
"sogo_profile_reset_help": "Esto destruirá el perfil de SOGo del usuario y <b>eliminará de forma irrecuperable todos sus contactos y datos de calendario</b>.",
"spam_score_reset": "Restablecer al valor predeterminado del servidor",
"spamfilter_table_domain_policy": "n/d (política del dominio)",
"syncjob_check_log": "Revisar registro",
"syncjob_EX_OK": "Correcto",
"syncjob_EXIT_OVERQUOTA": "El buzón de destino superó su cuota",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Usuario o contraseña incorrectos",
"tfa_info": "La autenticación de dos factores ayuda a proteger su cuenta. Si la activa, necesitará contraseñas de aplicación para iniciar sesión en aplicaciones o servicios que no admiten la autenticación de dos factores (p. ej., clientes de correo)."
},
"warning": {
"domain_added_sogo_failed": "Se agregó el dominio pero no se pudo reiniciar SOGo, revisa los logs del servidor.",
"domain_added_sogo_failed": "Se agregó el dominio, pero no se pudo reiniciar SOGo; revise los registros del servidor.",
"fuzzy_learn_error": "Error aprendiendo hash: %s",
"ip_invalid": "IP inválida omitida: %s",
"cannot_delete_self": "No se puede eliminar el usuario conectado"
"cannot_delete_self": "No se puede eliminar el usuario conectado",
"dovecot_restart_failed": "No se pudo reiniciar Dovecot, revise los registros",
"is_not_primary_alias": "Se omitió el alias no principal %s",
"quota_exceeded_scope": "Se superó la cuota del dominio: en este dominio solo se pueden crear buzones ilimitados.",
"session_token": "Token de formulario no válido: los tokens no coinciden",
"session_ua": "Token de formulario no válido: error de validación del User-Agent"
},
"datatables": {
"collapse_all": "Contraer todo",
@@ -1194,5 +1391,12 @@
"register_status": "Estado de registro",
"rename": "Renombrar",
"start_fido2_validation": "Iniciar validación FIDO2"
},
"ratelimit": {
"disabled": "Desactivado",
"second": "mensajes / segundo",
"minute": "mensajes / minuto",
"hour": "mensajes / hora",
"day": "mensajes / día"
}
}
+2
View File
@@ -81,6 +81,7 @@
"admin": {
"access": "Hallinta",
"action": "Toiminnot",
"activate_api": "Aktivoi API",
"activate_send": "Aktivoi Lähetä-painike",
"active": "Aktiivinen",
"active_rspamd_settings_map": "Aktiiviset asetukset",
@@ -667,6 +668,7 @@
"success": {
"acl_saved": "Objektin ACL %s tallenettu",
"admin_added": "Järjestelmänvalvoja %s on lisätty",
"admin_api_modified": "API-muutokset on tallennettu",
"admin_modified": "Järjestelmänvalvojan muutokset on tallennettu",
"admin_removed": "Ylläpitäjä %s on poistettu",
"alias_added": "Alias osoite %s (%d) on lisätty",
+2
View File
@@ -102,6 +102,7 @@
"admin": {
"access": "Accès",
"action": "Action",
"activate_api": "Activer l'API",
"activate_send": "Activer le bouton d'envoi",
"active": "Actif",
"active_rspamd_settings_map": "Paramètres de mappage actifs",
@@ -1042,6 +1043,7 @@
"success": {
"acl_saved": "ACL (Access Control List) pour l'objet %s enregistré",
"admin_added": "Administrateur %s a été ajouté",
"admin_api_modified": "Les modifications apportées à l’API ont été enregistrées",
"admin_modified": "Les modifications apportées à l’administrateur ont été enregistrées",
"admin_removed": "Administrateur %s a été effacé",
"alias_added": "L'adresse alias %s (%d) a été ajoutée",
+2
View File
@@ -19,6 +19,7 @@
"upload": "Feltöltés",
"username": "Felhasználónév",
"verify": "Ellenőrzés",
"activate_api": "API aktiválása",
"activate_send": "Küldés gomb aktiválása",
"add": "Hozzáad",
"active": "Aktív",
@@ -726,6 +727,7 @@
},
"success": {
"admin_added": "Adminisztrátor %s hozzáadva",
"admin_api_modified": "APImódosítások mentve",
"admin_modified": "Adminisztrátor módosításai mentve",
"admin_removed": "Adminisztrátor %s eltávolítva",
"alias_added": "Alias cím %s (%d) hozzáadva",
+2
View File
@@ -103,6 +103,7 @@
"admin": {
"access": "Accedi",
"action": "Azione",
"activate_api": "Attiva API",
"activate_send": "Attiva bottone di invio",
"active": "Attiva",
"active_rspamd_settings_map": "Mappa delle impostazioni attive",
@@ -978,6 +979,7 @@
"success": {
"acl_saved": "ACL for object %s saved",
"admin_added": "L'amministratore %s è stato aggiunto",
"admin_api_modified": "Le modifiche alle API sono state salvate",
"admin_modified": "I cambiamenti all'amministratore sono stati salvati",
"admin_removed": "L'amministratore %s è stato rimosso",
"alias_added": "Uno o più alias sono stati correttamente aggiunti",
+2
View File
@@ -99,6 +99,7 @@
"admin": {
"access": "アクセス",
"action": "アクション",
"activate_api": "APIを有効化",
"activate_send": "送信ボタンを有効化",
"active": "アクティブ",
"active_rspamd_settings_map": "アクティブな設定マップ",
@@ -1001,6 +1002,7 @@
"success": {
"acl_saved": "オブジェクト %s のACLを保存しました",
"admin_added": "管理者 %s が追加されました",
"admin_api_modified": "APIの変更が保存されました",
"admin_modified": "管理者の変更が保存されました",
"admin_removed": "管理者 %s が削除されました",
"alias_added": "エイリアスアドレス %s (%d) が追加されました",
+2
View File
@@ -98,6 +98,7 @@
"admin": {
"access": "접근",
"action": "동작",
"activate_api": "API 활성화",
"activate_send": "발신 버튼 활성화",
"active": "활성화",
"active_rspamd_settings_map": "설정 맵 활성화",
@@ -853,6 +854,7 @@
"success": {
"acl_saved": "ACL for object %s saved",
"admin_added": "Administrator %s has been added",
"admin_api_modified": "Changes to API have been saved",
"admin_modified": "Changes to administrator have been saved",
"admin_removed": "Administrator %s has been removed",
"alias_added": "Alias address %s (%d) has been added",
+1
View File
@@ -79,6 +79,7 @@
"admin": {
"access": "Pieeja",
"action": "Rīcība",
"activate_api": "Aktivizēt API",
"active": "Aktīvs",
"add": "Pievienot",
"add_domain_admin": "Pievienot domēna pārvaldītāju",
+1
View File
@@ -27,6 +27,7 @@
"access": "Tilgang",
"add_domain_admin": "Legg til domeneadministrator",
"add_forwarding_host": "Legg til videresendingsvert",
"activate_api": "Aktivér API",
"add_relayhost_hint": "Vennligst vær oppmerksom på at autentiseringsdata, om noe, vil bli lagret i klartekst.",
"app_links": "App-lenker",
"app_name": "App-navn",
+2
View File
@@ -100,6 +100,7 @@
"admin": {
"access": "Toegang",
"action": "Handeling",
"activate_api": "Activeer API",
"activate_send": "Bevestig bovenstaande gegevens",
"active": "Actief",
"active_rspamd_settings_map": "Huidige instellingen",
@@ -1034,6 +1035,7 @@
"success": {
"acl_saved": "Toegangscontrole voor object %s is opgeslagen",
"admin_added": "Administrator %s is toegevoegd",
"admin_api_modified": "Wijzigingen aan de API zijn opgeslagen",
"admin_modified": "Wijzigingen aan administrator zijn opgeslagen",
"admin_removed": "Administrator %s is verwijderd",
"alias_added": "Aliasadres %s (%d) is toegevoegd",
+2
View File
@@ -153,6 +153,7 @@
"time": "Czas",
"unchanged_if_empty": "W przypadku braku zmian, nie wypełniaj",
"username": "Nazwa użytkownika",
"activate_api": "Aktywuj API",
"activate_send": "Aktywuj przycisk wysyłania",
"active_rspamd_settings_map": "Aktywne ustawienia mapy",
"add_admin": "Dodaj administratora",
@@ -918,6 +919,7 @@
"verified_yotp_login": "Zweryfikowany login Yubico OTP",
"acl_saved": "ACL dla obiektu %s zapisany",
"admin_added": "Administrator %s został dodany",
"admin_api_modified": "Zmiany w API zostały zapisane",
"admin_removed": "Administrator %s został usunięty",
"app_links": "Zapisane zmiany w linkach aplikacji",
"app_passwd_added": "Dodano nowe hasło aplikacji",
+2
View File
@@ -102,6 +102,7 @@
"admin": {
"access": "Acesso",
"action": "Ação",
"activate_api": "Ativar API",
"activate_send": "Ativar botão de envio",
"active": "Ativo",
"active_rspamd_settings_map": "Mapa de configurações ativas",
@@ -1091,6 +1092,7 @@
"success": {
"acl_saved": "ACL para o objeto %s salvo",
"admin_added": "O administrador %s foi adicionado",
"admin_api_modified": "As alterações na API foram salvas",
"admin_modified": "As alterações no administrador foram salvas",
"admin_removed": "O administrador %s foi removido",
"alias_added": "O endereço de alias %s (%d) foi adicionado",
+1
View File
@@ -84,6 +84,7 @@
"customize": "Personalizar",
"destination": "Destino",
"dkim_from": "De",
"activate_api": "Ativar API",
"add_admin": "Adicionar administrador",
"admins": "Administradores",
"advanced_settings": "Configurações avançadas",
+2
View File
@@ -98,6 +98,7 @@
"admin": {
"access": "Acces",
"action": "Acțiune",
"activate_api": "Activează API",
"activate_send": "Activează butonul de trimitere",
"active": "Activ",
"active_rspamd_settings_map": "Harta setărilor active",
@@ -888,6 +889,7 @@
"success": {
"acl_saved": "ACL pentru obiectul %s a fost salvat",
"admin_added": "Administratorul %s a fost adăugat",
"admin_api_modified": "Modificările API-ului au fost salvate",
"admin_modified": "Modificările administratorului au fost salvate",
"admin_removed": "Administratorul %s a fost eliminat",
"alias_added": "Adresa alias %s a fost adăugată",
+2
View File
@@ -102,6 +102,7 @@
"admin": {
"access": "Настройки доступа",
"action": "Действия",
"activate_api": "Включить API",
"activate_send": "Активировать кнопку отправки",
"active": "Активный",
"active_rspamd_settings_map": "Отобразить текущие правила",
@@ -1087,6 +1088,7 @@
"success": {
"acl_saved": "ACL для %s сохранено",
"admin_added": "Администратор %s добавлен",
"admin_api_modified": "Изменения в API сохранены",
"admin_modified": "Изменения администратора сохранены",
"admin_removed": "Администратор %s удалён",
"alias_added": "Псевдоним %s (%d) добавлен",
+2
View File
@@ -103,6 +103,7 @@
"admin": {
"access": "Dostop",
"action": "Dejanje",
"activate_api": "Aktiviraj API",
"activate_send": "Aktiviraj gumb \"Pošlji\"",
"active": "Aktivno",
"active_rspamd_settings_map": "Aktivna preslikava nastavitev",
@@ -1099,6 +1100,7 @@
"relayhost_removed": "Vnos na zemljevidu %s je bil odstranjen",
"acl_saved": "ACL za objekt %s shranjen",
"admin_added": "Dodan je bil skrbnik %s",
"admin_api_modified": "Spremembe API-ja so bile shranjene",
"admin_modified": "Spremembe skrbnika so bile shranjene",
"admin_removed": "Administrator %s je bil odstranjen",
"alias_domain_removed": "Vzdevek domene %s je bil odstranjen",
+2
View File
@@ -98,6 +98,7 @@
"admin": {
"access": "Prístup",
"action": "Akcia",
"activate_api": "Aktivovať API",
"activate_send": "Aktivovať tlačidlo odoslania",
"active": "Aktívny",
"active_rspamd_settings_map": "Aktívne nastavenia",
@@ -934,6 +935,7 @@
"success": {
"acl_saved": "ACL pre objekt %s uložený",
"admin_added": "Administrátor %s pridaný",
"admin_api_modified": "Zmeny v API uložené",
"admin_modified": "Zmeny v administrácii uložené",
"admin_removed": "Administrátor %s odstránený ",
"alias_added": "Alias adresa %s (%d) pridaná",
+2
View File
@@ -97,6 +97,7 @@
"admin": {
"access": "Åtkomst",
"action": "Åtgärd",
"activate_api": "Aktivera API:et",
"activate_send": "Aktivera skicka-knappen",
"active": "Aktiv",
"active_rspamd_settings_map": "Nuvarande inställningsprofil",
@@ -817,6 +818,7 @@
"success": {
"acl_saved": "Åtkomstlistan för objektet %s har sparats",
"admin_added": "Administratören %s har lagts till",
"admin_api_modified": "Ändringarna på API:et har sparats",
"admin_modified": "Ändringarna för administratören har sparats",
"admin_removed": "Administratören %s har tagits bort",
"alias_added": "Aliasadressen %s (%d) har lagts till",
+2
View File
@@ -196,6 +196,7 @@
"f2b_list_info": "Kara listeye alınmış bir ana bilgisayar veya ağ, her zaman beyaz listedeki bir varlıktan daha ağır basacaktır. <b>Liste güncellemelerinin uygulanması birkaç saniye sürecektir.</b>",
"ip_check": "IP Kontrol",
"access": "Erişim",
"activate_api": "API etkinleştir",
"activate_send": "Gönder düğmesini etkinleştir",
"active": "Etkinleştir",
"active_rspamd_settings_map": "Aktif ayarlar haritası",
@@ -894,6 +895,7 @@
"template_added": "%s şablonu eklendi",
"acl_saved": "%s nesnesi için ACL kaydedildi",
"admin_added": "%s yöneticisi eklendi",
"admin_api_modified": "API'deki değişiklikler kaydedildi",
"admin_modified": "Yönetici değişiklikleri kaydedildi",
"admin_removed": "%s yöneticisi kaldırıldı",
"alias_domain_removed": "Takma ad alan adı %s kaldırıldı",
+2
View File
@@ -276,6 +276,7 @@
"validate_license_now": "Перевірити ліцензію на основі GUID із сервера ліцензій",
"verify": "Перевірити",
"yes": "&#10003;",
"activate_api": "Увімкнути API",
"active_rspamd_settings_map": "Відобразити поточні правила",
"add_relayhost": "Додавання проміжного вузла",
"add_relayhost_hint": "Будь ласка, майте на увазі, що дані для автентифікації зберігатимуться у вигляді звичайного тексту.",
@@ -953,6 +954,7 @@
"success": {
"acl_saved": "ACL для %s збережено",
"admin_added": "Адміністратора %s додано",
"admin_api_modified": "Зміни в API збережено",
"admin_modified": "Зміни адміністратора збережено",
"admin_removed": "Адміністратора %s видалено",
"alias_added": "Псевдонім %s (%d) додано",
+2
View File
@@ -102,6 +102,7 @@
"admin": {
"access": "Kirish",
"action": "Amal",
"activate_api": "API ni faollashtirish",
"activate_send": "Joʻnatish tugmasini faollashtirish",
"active": "Faol",
"active_rspamd_settings_map": "Faol sozlamalar xaritasi",
@@ -1089,6 +1090,7 @@
"success": {
"acl_saved": "%s obyekti uchun ACL saqlandi",
"admin_added": "Administrator %s qoʻshildi",
"admin_api_modified": "API ga oʻzgarishlar saqlandi",
"admin_modified": "Administrator oʻzgarishlari saqlandi",
"admin_removed": "Administrator %s olib tashlandi",
"alias_added": "Taxallus manzili %s (%d) qoʻshildi",
+1
View File
@@ -102,6 +102,7 @@
"admin": {
"access": "Truy cập",
"action": "Hành động",
"activate_api": "Kích hoạt API",
"activate_send": "Kích hoạt nút gửi",
"active": "Đang hoạt động",
"active_rspamd_settings_map": "Ánh xạ cài đặt đang hoạt động",
+2
View File
@@ -103,6 +103,7 @@
"admin": {
"access": "权限管理",
"action": "操作",
"activate_api": "启用 API",
"activate_send": "启用发送按钮",
"active": "启用",
"active_rspamd_settings_map": "启用的规则",
@@ -1066,6 +1067,7 @@
"success": {
"acl_saved": "已保存对象 %s 的 ACL 设置",
"admin_added": "已添加管理员 %s",
"admin_api_modified": "已保存 API 的更改",
"admin_modified": "已保存管理员更改",
"admin_removed": "已删除管理员 %s",
"alias_added": "已添加别名地址 %s (%d)",
+2
View File
@@ -99,6 +99,7 @@
"admin": {
"access": "存取",
"action": "動作",
"activate_api": "啟用 API",
"activate_send": "啟用發送按鈕",
"active": "啟用",
"active_rspamd_settings_map": "啟用設定規則",
@@ -951,6 +952,7 @@
"success": {
"acl_saved": "%s 的存取權限表已儲存",
"admin_added": "管理員 %s 已新增",
"admin_api_modified": "API 的更改已儲存",
"admin_modified": "管理員更改已儲存",
"admin_removed": "管理員 %s 已刪除",
"alias_added": "別名地址 %s (%d) 已新增",
+135 -60
View File
@@ -87,6 +87,141 @@
</div>
<br>
</div>
<legend style="cursor:pointer;margin-top:20px" data-bs-target="#admin_api" unselectable="on" data-bs-toggle="collapse">
<i style="font-size:10pt;" class="bi bi-plus-square"></i> API
</legend>
<hr />
<div id="admin_api" class="collapse">
<div class="row">
<div class="col-lg-12">
<p class="text-muted">{{ lang.admin.api_info|raw }}</p>
</div>
<div class="col-lg-12">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title"><i class="bi bi-file-earmark-arrow-down"></i> {{ lang.admin.cors_settings }}</h4>
</div>
<div class="card-body">
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" data-id="editcors" method="post">
<div class="row mb-4">
<label class="control-label col-sm-2 mb-4" for="allowed_origins">{{ lang.admin.allowed_origins }}</label>
<div class="col-sm-9 mb-4">
<textarea class="form-control textarea-code" rows="7" name="allowed_origins" id="allowed_origins">{{ cors_settings.allowed_origins }}</textarea>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="allowed_methods">{{ lang.admin.allowed_methods }}</label>
<div class="col-sm-9">
<select name="allowed_methods" id="allowed_methods" multiple class="form-control">
<option value="POST"{% if "POST" in cors_settings.allowed_methods %} selected{% endif %}>POST</option>
<option value="GET"{% if "GET" in cors_settings.allowed_methods %} selected{% endif %}>GET</option>
<option value="DELETE"{% if "DELETE" in cors_settings.allowed_methods %} selected{% endif %}>DELETE</option>
<option value="PUT"{% if "PUT" in cors_settings.allowed_methods %} selected{% endif %}>PUT</option>
</select>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-9 d-grid d-sm-block">
<button class="btn btn-sm btn-xs-lg btn-success" data-item="cors" data-api-url="edit/cors" data-id="editcors" data-action="edit_selected" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title"><i class="bi bi-file-earmark-arrow-down"></i> {{ lang.admin.api_read_only }}</h4>
</div>
<div class="card-body">
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" method="post">
<div class="row mb-4">
<label class="control-label col-sm-3" for="allow_from_ro">{{ lang.admin.api_allow_from }}:</label>
<div class="col-sm-9">
<textarea class="form-control textarea-code" rows="7" name="allow_from" id="allow_from_ro" {% if api.ro.skip_ip_check %}disabled{% endif %} required>{{ api.ro.allow_from }}</textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="skip_ip_check" id="skip_ip_check_ro" {% if api.ro.skip_ip_check %}checked{% endif %}> {{ lang.admin.api_skip_ip_check }}
</label>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-3">{{ lang.admin.api_key }}:</label>
<div class="col-sm-9">
<input type="text" class="form-control" value="{{ api.ro.api_key|default('-') }}" readonly />
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="active" {% if api.ro.active %}checked{% endif %}> {{ lang.admin.activate_api }}
</label>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="btn-group">
<button class="btn btn-sm btn-xs-lg btn-xs-half d-block d-sm-inline btn-success" name="admin_api[ro]" type="submit" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-sm btn-xs-lg btn-xs-half d-block d-sm-inline btn-secondary admin-ays-dialog" name="admin_api_regen_key[ro]" type="submit" href="#" {% if not api.ro.api_key %}disabled{% endif %}>{{ lang.admin.regen_api_key }}</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card mb-3">
<div class="card-header">
<h4 class="card-title"><i class="bi bi-file-earmark-diff"></i> {{ lang.admin.api_read_write }}</h4>
</div>
<div class="card-body">
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" method="post">
<div class="row mb-4">
<label class="control-label col-sm-3" for="allow_from_rw">{{ lang.admin.api_allow_from }}:</label>
<div class="col-sm-9">
<textarea class="form-control textarea-code" rows="7" name="allow_from" id="allow_from_rw" {% if api.rw.skip_ip_check %}disabled{% endif %} required>{{ api.rw.allow_from }}</textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="skip_ip_check" id="skip_ip_check_rw" {% if api.rw.skip_ip_check %}checked{% endif %}> {{ lang.admin.api_skip_ip_check }}
</label>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-3" for="admin_api_key">{{ lang.admin.api_key }}:</label>
<div class="col-sm-9">
<input type="text" class="form-control" value="{{ api.rw.api_key|default('-') }}" readonly />
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<label>
<input type="checkbox" class="form-check-input" name="active" {% if api.rw.active %}checked{% endif %}> {{ lang.admin.activate_api }}
</label>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-3 col-sm-9">
<div class="btn-group">
<button class="btn btn-sm btn-xs-half d-block d-sm-inline btn-success" name="admin_api[rw]" type="submit" 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 admin-ays-dialog" name="admin_api_regen_key[rw]" type="submit" {% if not api.rw.api_key %}disabled{% endif %} href="#">{{ lang.admin.regen_api_key }}</button>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -116,64 +251,4 @@
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header d-flex fs-5">
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-api" data-bs-toggle="collapse" aria-controls="collapse-tab-config-api">
API
</button>
<span class="d-none d-md-block">API</span>
</div>
<div id="collapse-tab-config-api" class="card-body collapse" data-bs-parent="#admin-content">
<p class="text-muted">{{ lang.admin.api_info|raw }}</p>
{# API keys #}
<legend style="margin-top:20px"><i class="bi bi-key"></i> {{ lang.admin.api_keys }}</legend>
<hr />
<table id="apikeystable" class="table table-striped dt-responsive w-100"></table>
<div class="mass-actions-admin mb-4">
<div class="btn-group">
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" id="toggle_multi_select_all" data-id="api_keys" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
<ul class="dropdown-menu">
<li><a class="dropdown-item" data-action="edit_selected" data-id="api_keys" data-api-url='edit/api-key' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
<li><a class="dropdown-item" data-action="edit_selected" data-id="api_keys" data-api-url='edit/api-key' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item admin-ays-dialog" data-action="edit_selected" data-id="api_keys" data-api-url='edit/api-key' data-api-attr='{"regen_key":"1"}' href="#">{{ lang.admin.regen_api_key }}</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" data-action="delete_selected" data-id="api_keys" data-api-url='delete/api-key' href="#">{{ lang.mailbox.remove }}</a></li>
</ul>
<a class="btn btn-sm d-block d-sm-inline btn-success" data-id="add_api_key" data-bs-toggle="modal" data-bs-target="#addApiKeyModal" href="#"><i class="bi bi-plus-lg"></i> {{ lang.admin.api_key_add }}</a>
</div>
</div>
{# CORS #}
<legend style="margin-top:20px"><i class="bi bi-file-earmark-arrow-down"></i> {{ lang.admin.cors_settings }}</legend>
<hr />
<form class="form-horizontal" autocapitalize="none" autocorrect="off" role="form" data-id="editcors" method="post">
<div class="row mb-4">
<label class="control-label col-sm-2" for="allowed_origins">{{ lang.admin.allowed_origins }}</label>
<div class="col-sm-9">
<textarea class="form-control textarea-code" rows="7" name="allowed_origins" id="allowed_origins">{{ cors_settings.allowed_origins }}</textarea>
</div>
</div>
<div class="row mb-4">
<label class="control-label col-sm-2" for="allowed_methods">{{ lang.admin.allowed_methods }}</label>
<div class="col-sm-9">
<select name="allowed_methods" id="allowed_methods" multiple class="form-control">
<option value="POST"{% if "POST" in cors_settings.allowed_methods %} selected{% endif %}>POST</option>
<option value="GET"{% if "GET" in cors_settings.allowed_methods %} selected{% endif %}>GET</option>
<option value="DELETE"{% if "DELETE" in cors_settings.allowed_methods %} selected{% endif %}>DELETE</option>
<option value="PUT"{% if "PUT" in cors_settings.allowed_methods %} selected{% endif %}>PUT</option>
</select>
</div>
</div>
<div class="row">
<div class="offset-sm-2 col-sm-9 d-grid d-sm-block">
<button class="btn btn-sm btn-xs-lg btn-success" data-item="cors" data-api-url="edit/cors" data-id="editcors" data-action="edit_selected" href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
-71
View File
@@ -1,71 +0,0 @@
{% extends 'edit.twig' %}
{% block inner_content %}
{% if result %}
{% set locked = result.service == 1 %}
<h4>{{ lang.admin.api_key }}</h4>
{% if locked %}
<div class="alert alert-secondary" role="alert"><i class="bi bi-lock-fill"></i> {{ lang.admin.api_service_key_info }}</div>
{% endif %}
<form data-id="edit_api_key" class="form-horizontal" role="form" method="post" autocapitalize="none" autocorrect="off">
<input type="hidden" value="0" name="skip_ip_check">
<input type="hidden" value="0" name="active">
<div class="row mb-2">
<label class="control-label col-sm-2" for="api_key">{{ lang.admin.api_key }}</label>
<div class="col-sm-10">
<div class="input-group">
<input type="text" class="form-control textarea-code" id="api_key" value="{{ result.api_key }}" readonly>
<button type="button" class="btn btn-secondary api-key-copy" data-key="{{ result.api_key }}"><i class="bi bi-clipboard"></i></button>
</div>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="description">{{ lang.admin.description }}</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="description" id="description" value="{{ result.description }}" maxlength="255"{% if locked %} disabled{% endif %}>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="access">{{ lang.admin.access }}</label>
<div class="col-sm-10">
<select class="form-control" name="access" id="access"{% if locked %} disabled{% endif %}>
<option value="ro"{% if result.access == 'ro' %} selected{% endif %}>{{ lang.admin.api_read_only }}</option>
<option value="rw"{% if result.access == 'rw' %} selected{% endif %}>{{ lang.admin.api_read_write }}</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2" for="allow_from">{{ lang.admin.api_allow_from }}</label>
<div class="col-sm-10">
<textarea class="form-control textarea-code" rows="5" name="allow_from" id="allow_from"{% if locked or result.skip_ip_check == 1 %} disabled{% endif %}>{{ result.allow_from|join("\n") }}</textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input api-skip-ip-check" value="1" name="skip_ip_check" data-target="#allow_from"{% if result.skip_ip_check == 1 %} checked{% endif %}{% if locked %} disabled{% endif %}> {{ lang.admin.api_skip_ip_check }}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if result.active == 1 %} checked{% endif %}{% if locked %} disabled{% endif %}> {{ lang.edit.active }}</label>
</div>
</div>
</div>
{% if not locked %}
<div class="row">
<div class="offset-sm-2 col-sm-10">
<div class="btn-group">
<button class="btn btn-xs-lg btn-xs-half d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_api_key" data-item="{{ result.id }}" data-api-url='edit/api-key' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
<button class="btn btn-xs-lg btn-xs-half d-block d-sm-inline btn-secondary api-key-regen" data-action="edit_selected" data-id="regen_api_key" data-item="{{ result.id }}" data-api-url='edit/api-key' data-api-attr='{"regen_key":"1"}' href="#"><i class="bi bi-arrow-repeat"></i> {{ lang.admin.regen_api_key }}</button>
</div>
</div>
</div>
{% endif %}
</form>
{% else %}
{{ parent() }}
{% endif %}
{% endblock %}
-57
View File
@@ -150,63 +150,6 @@
</div>
</div>
</div><!-- add domain admin modal -->
<!-- add api key modal -->
<div class="modal fade" id="addApiKeyModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title">{{ lang.admin.api_key_add }}</h3>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form class="form-horizontal" data-id="add_api_key" role="form" method="post" autocapitalize="none" autocorrect="off">
<input type="hidden" value="0" name="skip_ip_check">
<input type="hidden" value="0" name="active">
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_api_description">{{ lang.admin.description }}:</label>
<div class="col-sm-10">
<input type="text" class="form-control" name="description" id="add_api_description" maxlength="255">
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_api_access">{{ lang.admin.access }}:</label>
<div class="col-sm-10">
<select class="form-control" name="access" id="add_api_access">
<option value="ro">{{ lang.admin.api_read_only }}</option>
<option value="rw">{{ lang.admin.api_read_write }}</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-2 text-sm-end" for="add_api_allow_from">{{ lang.admin.api_allow_from }}:</label>
<div class="col-sm-10">
<textarea class="form-control textarea-code" rows="5" name="allow_from" id="add_api_allow_from" required></textarea>
</div>
</div>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input api-skip-ip-check" value="1" name="skip_ip_check" data-target="#add_api_allow_from"> {{ lang.admin.api_skip_ip_check }}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<div class="form-check">
<label><input type="checkbox" class="form-check-input" value="1" name="active" checked> {{ lang.admin.active }}</label>
</div>
</div>
</div>
<div class="row mb-4">
<div class="offset-sm-2 col-sm-10">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="add_api_key" data-api-url='add/api-key' data-api-attr='{}' href="#"><i class="bi bi-plus-lg"></i> {{ lang.admin.add }}</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div><!-- add api key modal -->
<!-- add admin modal -->
<div class="modal fade" id="addAdminModal" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-xl">
+2 -2
View File
@@ -403,8 +403,8 @@ IPV6_NETWORK=fd4d:6169:6c63:6f77::/64
# Use this IPv6 for outgoing connections (SNAT)
#SNAT6_TO_SOURCE=
# Service API keys: shown in the web UI, but can only be changed here (more keys can be added in the UI)
# You _must_ define API_ALLOW_FROM, which is a comma separated list of IPs/networks
# Create or override an API key for the web UI
# You _must_ define API_ALLOW_FROM, which is a comma separated list of IPs
# An API key defined as API_KEY has read-write access
# An API key defined as API_KEY_READ_ONLY has read-only access
# Allowed chars for API_KEY and API_KEY_READ_ONLY: a-z, A-Z, 0-9, -
@@ -1,116 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\ApiKey\Controller;
use Mailcow\ApiKey\Controller\ApiKeyApiController;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use PDOException;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ApiKeyApiControllerTest extends TestCase
{
private ApiKeyService&MockObject $keys;
private ApiKeyApiController $api;
protected function setUp(): void
{
$_SESSION = [];
$this->keys = $this->createMock(ApiKeyService::class);
$this->api = new ApiKeyApiController($this->keys, new ApiResponse(['api_key']));
}
private static function key(int $id, string $description = ''): ApiKey
{
return ApiKey::fromRow(['id' => $id, 'api_key' => 'KEY-' . $id, 'description' => $description, 'access' => 'rw',
'allow_from' => '192.0.2.1']);
}
/** Messages written to the session as [type, msg] */
private function messages(): array
{
return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []);
}
public function testAddLogsWithoutKeyAndNamesTheKey(): void
{
$this->keys->method('create')->willReturn(self::key(4, '<crm>'));
$this->assertTrue($this->api->addKey(['access' => 'rw', 'api_key' => 'chosen']));
$this->assertSame([[
'type' => 'success',
'log' => ['api', 'add', 'key', ['access' => 'rw', 'api_key' => '*']],
'msg' => ['api_key_added', '&lt;crm&gt;'],
]], $_SESSION['return']);
}
public function testErrorsBecomeDangerMessages(): void
{
$this->keys->method('create')->willReturnOnConsecutiveCalls(
$this->throwException(new UserError('ip_list_empty')),
$this->throwException(new PDOException('gone away'))
);
$this->assertFalse($this->api->addKey([]));
$this->assertFalse($this->api->addKey([]));
$this->assertSame([['danger', 'ip_list_empty'], ['danger', ['mysql_error', 'gone away']]], $this->messages());
}
public function testEditReportsEachIdAndSkipsInvalidIds(): void
{
$this->keys->method('update')->willReturnCallback(fn(int $id, array $attr): ApiKey => ($id === 2)
? throw new UserError('api_key_service_locked', ['API_KEY']) : self::key($id));
$this->assertTrue($this->api->editKeys(['1', '2', 'x'], ['active' => '0']));
$this->assertSame([
['success', ['api_key_modified', '1']],
['danger', ['api_key_service_locked', 'API_KEY']],
['danger', 'access_denied'],
], $this->messages());
}
public function testEditAndDeleteNeedAdminOnce(): void
{
$this->keys->method('requireAdmin')->willThrowException(new AccessDenied());
$this->keys->expects($this->never())->method('update');
$this->keys->expects($this->never())->method('delete');
$this->assertFalse($this->api->editKeys(['1', '2'], []));
$this->assertFalse($this->api->deleteKeys(['1', '2']));
$this->assertSame([['danger', 'access_denied'], ['danger', 'access_denied']], $this->messages());
}
public function testDelete(): void
{
$this->keys->expects($this->once())->method('delete')->with(5)->willReturn(self::key(5, 'old'));
$this->assertTrue($this->api->deleteKeys(['5']));
$this->assertSame([['success', ['api_key_deleted', 'old']]], $this->messages());
}
public function testGetReturnsApiShape(): void
{
$this->keys->method('all')->willReturn([self::key(1, 'crm')]);
$this->keys->method('get')->willReturnMap([[1, self::key(1, 'crm')], [2, null]]);
$this->assertSame([[
'id' => 1, 'description' => 'crm', 'access' => 'rw', 'api_key' => 'KEY-1', 'allow_from' => ['192.0.2.1'],
'skip_ip_check' => 0, 'service' => 0, 'active' => 1, 'created' => null, 'modified' => null,
]], $this->api->getKeys());
$this->assertSame('KEY-1', $this->api->getKey('1')['api_key']);
$this->assertSame([], $this->api->getKey('2'));
$this->assertSame([], $this->api->getKey('1.5'));
$this->assertSame([], $this->api->getKey('all'));
}
}
@@ -1,255 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\ApiKey\Service;
use Mailcow\ApiKey\Model\ApiKey;
use Mailcow\ApiKey\Repository\ApiKeyRepository;
use Mailcow\ApiKey\Service\ApiKeyService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeContext;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class ApiKeyServiceTest extends TestCase
{
private const KEY_FORMAT = '/^([0-9A-F]{6}-){4}[0-9A-F]{6}$/';
private ApiKeyRepository&MockObject $repo;
protected function setUp(): void
{
$this->repo = $this->createMock(ApiKeyRepository::class);
}
private function service(?Context $context = null): ApiKeyService
{
return new ApiKeyService($this->repo, $context ?? FakeContext::admin());
}
private static function apiContext(string $access): Context
{
return new Context('admin', 'API', [], false, $access);
}
private static function stored(array $row = []): ApiKey
{
return ApiKey::fromRow($row + ['id' => 3, 'api_key' => 'AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE', 'description' => 'crm',
'access' => 'ro', 'allow_from' => '192.0.2.1,2001:db8::/64', 'skip_ip_check' => 0, 'service' => 0, 'active' => 1]);
}
private function expectUserError(string|array $msg, callable $call): void
{
try {
$call();
$this->fail('UserError ' . json_encode($msg) . ' expected');
} catch (UserError $e) {
$this->assertSame($msg, $e->msg());
}
}
public static function deniedCallers(): array
{
return [
'domain admin' => [FakeContext::domainAdmin()],
'user' => [FakeContext::user()],
'system' => [Context::system()],
'read-only API key' => [self::apiContext('ro')],
];
}
#[DataProvider('deniedCallers')]
public function testOnlyAdminsMayReadOrWrite(Context $context): void
{
$this->repo->expects($this->never())->method('all');
$this->repo->expects($this->never())->method('insert');
$service = $this->service($context);
$this->assertSame([], $service->all());
$this->assertNull($service->get(3));
$this->expectUserError('access_denied', fn() => $service->create(['access' => 'ro', 'skip_ip_check' => '1']));
$this->expectUserError('access_denied', fn() => $service->delete(3));
}
public function testReadWriteApiKeyMayManageKeys(): void
{
$this->repo->method('all')->willReturn([self::stored()]);
$this->assertCount(1, $this->service(self::apiContext('rw'))->all());
}
public function testCreateGeneratesKeyAndNormalisesNetworks(): void
{
$this->repo->expects($this->once())->method('insert')->with($this->callback(function (ApiKey $key): bool {
$this->assertMatchesRegularExpression(self::KEY_FORMAT, $key->key);
$this->assertSame(['192.0.2.1', '2001:db8::/64', '10.0.0.0/8'], $key->allowFrom);
$this->assertSame('rw', $key->access);
$this->assertSame('crm', $key->description);
$this->assertSame(0, $key->service);
// Hidden "0" + checked checkbox
$this->assertSame(1, $key->active);
$this->assertSame(0, $key->skipIpCheck);
return true;
}))->willReturn(7);
$this->service()->create(['description' => ' crm ', 'access' => 'rw', 'active' => ['0', '1'], 'skip_ip_check' => '0',
'allow_from' => "192.0.2.1\n2001:db8::/64, 10.0.0.0/8;192.0.2.1"]);
}
public function testCreateAcceptsNetworkArray(): void
{
$this->repo->expects($this->once())->method('insert')
->with($this->callback(fn(ApiKey $key): bool => $key->allowFrom === ['192.0.2.1', '::1']));
$this->service()->create(['access' => 'ro', 'allow_from' => ['192.0.2.1', ' ::1 ']]);
}
public static function invalidInput(): array
{
return [
'access missing' => [['allow_from' => '192.0.2.1'], 'api_key_access_invalid'],
'access unknown' => [['access' => 'admin', 'allow_from' => '192.0.2.1'], 'api_key_access_invalid'],
'no network' => [['access' => 'ro'], 'ip_list_empty'],
'only separators' => [['access' => 'ro', 'allow_from' => " ,\n"], 'ip_list_empty'],
'hostname' => [['access' => 'ro', 'allow_from' => 'mail.example.org'], ['api_key_network_invalid', 'mail.example.org']],
'v4 prefix too long' => [['access' => 'ro', 'allow_from' => '192.0.2.0/33'], ['api_key_network_invalid', '192.0.2.0/33']],
'empty prefix' => [['access' => 'ro', 'allow_from' => '192.0.2.0/'], ['api_key_network_invalid', '192.0.2.0/']],
'escaped' => [['access' => 'ro', 'allow_from' => '<b>'], ['api_key_network_invalid', '&lt;b&gt;']],
'description too long' => [['access' => 'ro', 'skip_ip_check' => '1', 'description' => str_repeat('x', 256)], 'api_key_description_invalid'],
'description array' => [['access' => 'ro', 'skip_ip_check' => '1', 'description' => ['x']], 'api_key_description_invalid'],
];
}
#[DataProvider('invalidInput')]
public function testCreateRejectsInvalidInput(array $input, string|array $msg): void
{
$this->repo->expects($this->never())->method('insert');
$this->expectUserError($msg, fn() => $this->service()->create($input));
}
public function testSkipIpCheckNeedsNoNetwork(): void
{
$this->repo->expects($this->once())->method('insert')
->with($this->callback(fn(ApiKey $key): bool => $key->skipIpCheck === 1 && $key->allowFrom === []));
$this->service()->create(['access' => 'ro', 'skip_ip_check' => 'true']);
}
public function testUpdateKeepsMissingAttributesAndKeyValue(): void
{
$this->repo->method('find')->with(3)->willReturn(self::stored());
$this->repo->expects($this->once())->method('update')->with($this->callback(function (ApiKey $key): bool {
$this->assertSame('AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE', $key->key);
$this->assertSame(['192.0.2.1', '2001:db8::/64'], $key->allowFrom);
$this->assertSame('ro', $key->access);
$this->assertSame('crm', $key->description);
$this->assertSame(0, $key->active);
return true;
}));
$this->service()->update(3, ['active' => '0']);
}
public function testUpdateRegeneratesKey(): void
{
$this->repo->method('find')->willReturn(self::stored());
$this->repo->expects($this->once())->method('update')->with($this->callback(fn(ApiKey $key): bool =>
preg_match(self::KEY_FORMAT, $key->key) === 1 && $key->key !== 'AAAAAA-BBBBBB-CCCCCC-DDDDDD-EEEEEE'));
$this->service()->update(3, ['regen_key' => '1']);
}
public function testDisablingSkipIpCheckNeedsNetworks(): void
{
$this->repo->method('find')->willReturn(self::stored(['allow_from' => '', 'skip_ip_check' => 1]));
$this->repo->expects($this->never())->method('update');
$this->expectUserError('ip_list_empty', fn() => $this->service()->update(3, ['skip_ip_check' => '0']));
}
public function testServiceKeysAreLocked(): void
{
$this->repo->method('find')->willReturn(self::stored(['service' => 1, 'description' => 'API_KEY']));
$this->repo->expects($this->never())->method('update');
$this->repo->expects($this->never())->method('delete');
$this->expectUserError(['api_key_service_locked', 'API_KEY'], fn() => $this->service()->update(3, ['regen_key' => '1']));
$this->expectUserError(['api_key_service_locked', 'API_KEY'], fn() => $this->service()->delete(3));
}
public function testMissingKeyIsAccessDenied(): void
{
$this->repo->method('find')->willReturn(null);
$this->expectUserError('access_denied', fn() => $this->service()->update(99, []));
$this->expectUserError('access_denied', fn() => $this->service()->delete(99));
}
public function testDeleteReturnsDeletedKey(): void
{
$this->repo->method('find')->willReturn(self::stored());
$this->repo->expects($this->once())->method('delete')->with(3);
$this->assertSame('crm', $this->service()->delete(3)->description);
}
public function testSyncMirrorsBothServiceKeys(): void
{
$this->repo->expects($this->once())->method('syncService')->with($this->callback(function (array $keys): bool {
$this->assertCount(2, $keys);
[$rw, $ro] = $keys;
$this->assertSame(['RW-KEY', 'API_KEY', 'rw', 1, ['192.0.2.1', '2001:db8::/64']],
[$rw->key, $rw->description, $rw->access, $rw->service, $rw->allowFrom]);
$this->assertSame(['RO-KEY', 'API_KEY_READ_ONLY', 'ro', 1, 1, 0],
[$ro->key, $ro->description, $ro->access, $ro->service, $ro->active, $ro->skipIpCheck]);
return true;
}));
$warnings = $this->service(Context::system())->syncServiceKeys([
'API_KEY' => 'RW-KEY', 'API_KEY_READ_ONLY' => ' RO-KEY ', 'API_ALLOW_FROM' => '192.0.2.1,2001:db8::/64,nonsense',
]);
$this->assertSame(['API_ALLOW_FROM: skipped invalid IP/network nonsense'], $warnings);
}
public static function unsetEnv(): array
{
return [
'compose defaults' => [['API_KEY' => 'invalid', 'API_KEY_READ_ONLY' => 'invalid', 'API_ALLOW_FROM' => 'invalid']],
'empty' => [['API_KEY' => '', 'API_ALLOW_FROM' => '192.0.2.1']],
'missing' => [[]],
];
}
#[DataProvider('unsetEnv')]
public function testSyncWithoutServiceKeysRemovesThem(array $env): void
{
$this->repo->expects($this->once())->method('syncService')->with([]);
$this->assertSame([], $this->service(Context::system())->syncServiceKeys($env));
}
public function testSyncSkipsInvalidKeys(): void
{
$this->repo->expects($this->once())->method('syncService')
->with($this->callback(fn(array $keys): bool => count($keys) === 1 && $keys[0]->description === 'API_KEY'));
$warnings = $this->service(Context::system())->syncServiceKeys([
'API_KEY' => 'SAME-KEY', 'API_KEY_READ_ONLY' => 'SAME-KEY', 'API_ALLOW_FROM' => '192.0.2.1',
]);
$this->assertSame(['API_KEY_READ_ONLY: same value as API_KEY, key ignored'], $warnings);
$this->repo = $this->createMock(ApiKeyRepository::class);
$this->repo->expects($this->once())->method('syncService')->with([]);
$warnings = $this->service(Context::system())->syncServiceKeys(['API_KEY' => 'bad"key', 'API_KEY_READ_ONLY' => 'RO-KEY',
'API_ALLOW_FROM' => 'invalid']);
$this->assertSame([
'API_KEY: invalid characters (allowed: a-z, A-Z, 0-9, -), key ignored',
'API_KEY_READ_ONLY: API_ALLOW_FROM contains no valid IP/network, key ignored',
], $warnings);
}
}
-16
View File
@@ -58,22 +58,6 @@ class ContextTest extends TestCase
$this->assertFalse(Context::fromSession(['acl' => 'not an array'])->hasAcl('syncjobs'));
}
public function testApiAccessComesFromTheApiKeySession(): void
{
$ro = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'ro']);
$rw = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'rw']);
$login = Context::fromSession(['mailcow_cc_role' => 'admin', 'mailcow_cc_api_access' => 'ro']);
$this->assertSame('ro', $ro->apiAccess);
$this->assertTrue($ro->isReadOnlyApi());
$this->assertSame('rw', $rw->apiAccess);
$this->assertFalse($rw->isReadOnlyApi());
$this->assertNull($login->apiAccess);
$this->assertFalse($login->isReadOnlyApi());
// Unknown access values are read-only
$this->assertTrue(Context::fromSession(['mailcow_cc_api' => true, 'mailcow_cc_api_access' => 'x'])->isReadOnlyApi());
}
public function testSystemContextIsNoAccount(): void
{
$context = Context::system();