mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-08 10:13:37 +00:00
[Web][Postfix] Add mail policies: per-recipient send/receive rules for mailboxes and groups
This commit is contained in:
36 files changed
+2677
-42
No files matched your search
@@ -11,6 +11,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/groups.js');
|
||||
$js_minifier->add('/web/js/site/mail-policy.js');
|
||||
$js_minifier->add('/web/js/site/mailbox.js');
|
||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
@@ -46,6 +47,7 @@ $template_data = [
|
||||
'lang_edit' => json_encode($lang['edit']),
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
+368
-2
@@ -1365,7 +1365,7 @@ paths:
|
||||
description: show the group address as SOGo identity (needs sender_allowed)
|
||||
type: boolean
|
||||
internal:
|
||||
description: only accept mail from senders of the domain
|
||||
description: only accept mail from logged-in senders of the domain
|
||||
type: boolean
|
||||
template_id:
|
||||
description: mailbox template applied to the members (admin only), empty = none
|
||||
@@ -1377,6 +1377,115 @@ paths:
|
||||
type: boolean
|
||||
type: object
|
||||
summary: Create group
|
||||
/api/v1/add/mail-policy:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- mail_policy
|
||||
- add
|
||||
- policy
|
||||
- domain: example.org
|
||||
name: Trainees
|
||||
send_mode: internal
|
||||
groups:
|
||||
- "7"
|
||||
msg:
|
||||
- mail_policy_added
|
||||
- Trainees
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- warning
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Mail policies
|
||||
description: >-
|
||||
Create a mail policy (admins; domain admins with the "mail_policies"
|
||||
ACL for their domains). Postfix checks the policies for every
|
||||
recipient: every policy of the sending mailbox and of the addressed
|
||||
mailbox or group must allow the mail, otherwise the recipient is
|
||||
refused with 550 5.7.1.
|
||||
operationId: Create mail policy
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
domain: example.org
|
||||
name: Trainees
|
||||
send_mode: internal
|
||||
send_allow:
|
||||
- partner.example
|
||||
receive_mode: all
|
||||
receive_block:
|
||||
- group:8
|
||||
groups:
|
||||
- "7"
|
||||
active: "1"
|
||||
properties:
|
||||
domain:
|
||||
description: domain of the policy (required)
|
||||
type: string
|
||||
name:
|
||||
description: unique per domain, max. 100 characters
|
||||
type: string
|
||||
description:
|
||||
description: max. 255 characters
|
||||
type: string
|
||||
send_mode:
|
||||
description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only
|
||||
type: string
|
||||
send_allow:
|
||||
description: addresses, domains or group:<id> (array or list separated by commas, spaces or newlines), max. 500
|
||||
type: array
|
||||
send_block:
|
||||
description: recipients that are always refused, entries as send_allow
|
||||
type: array
|
||||
receive_mode:
|
||||
description: all; internal = senders logged in with a mailbox of the domain plus receive_allow; list = receive_allow only
|
||||
type: string
|
||||
receive_allow:
|
||||
description: entries as send_allow; addresses and domains of this mailcow count for logged-in senders only
|
||||
type: array
|
||||
receive_block:
|
||||
description: senders that are always refused, entries as receive_allow
|
||||
type: array
|
||||
reject_text:
|
||||
description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text
|
||||
type: string
|
||||
mailboxes:
|
||||
description: mailboxes of the domain the policy applies to (replaces the list)
|
||||
type: array
|
||||
groups:
|
||||
description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list)
|
||||
type: array
|
||||
active:
|
||||
type: boolean
|
||||
type: object
|
||||
summary: Create mail policy
|
||||
/api/v1/add/oauth2-client:
|
||||
post:
|
||||
responses:
|
||||
@@ -2853,6 +2962,62 @@ paths:
|
||||
type: object
|
||||
type: object
|
||||
summary: Delete groups
|
||||
/api/v1/delete/mail-policy:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- mail_policy
|
||||
- delete
|
||||
- policy
|
||||
- id:
|
||||
- "3"
|
||||
msg:
|
||||
- mail_policy_removed
|
||||
- Trainees
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- warning
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Mail policies
|
||||
description: Delete mail policies by ID.
|
||||
operationId: Delete mail policies
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
- "3"
|
||||
properties:
|
||||
items:
|
||||
description: contains list of policy IDs you want to delete
|
||||
type: object
|
||||
type: object
|
||||
summary: Delete mail policies
|
||||
/api/v1/delete/identity-link:
|
||||
post:
|
||||
responses:
|
||||
@@ -4422,7 +4587,7 @@ paths:
|
||||
description: show the group address as SOGo identity (needs sender_allowed)
|
||||
type: boolean
|
||||
internal:
|
||||
description: only accept mail from senders of the domain
|
||||
description: only accept mail from logged-in senders of the domain
|
||||
type: boolean
|
||||
template_id:
|
||||
description: mailbox template applied to the members (admin only), empty = none
|
||||
@@ -4497,6 +4662,106 @@ paths:
|
||||
type: object
|
||||
type: object
|
||||
summary: Update groups
|
||||
/api/v1/edit/mail-policy:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- mail_policy
|
||||
- edit
|
||||
- policy
|
||||
- id:
|
||||
- "3"
|
||||
receive_mode: internal
|
||||
msg:
|
||||
- mail_policy_modified
|
||||
- Trainees
|
||||
type: success
|
||||
schema:
|
||||
properties:
|
||||
log:
|
||||
description: contains request object
|
||||
items: {}
|
||||
type: array
|
||||
msg:
|
||||
items: {}
|
||||
type: array
|
||||
type:
|
||||
enum:
|
||||
- success
|
||||
- danger
|
||||
- warning
|
||||
- error
|
||||
type: string
|
||||
type: object
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Mail policies
|
||||
description: >-
|
||||
Update mail policies. Attributes you do not send keep their value. The
|
||||
domain cannot be changed.
|
||||
operationId: Update mail policies
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
items:
|
||||
- "3"
|
||||
attr:
|
||||
receive_mode: internal
|
||||
properties:
|
||||
attr:
|
||||
properties:
|
||||
name:
|
||||
description: unique per domain, max. 100 characters
|
||||
type: string
|
||||
description:
|
||||
description: max. 255 characters
|
||||
type: string
|
||||
send_mode:
|
||||
description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only
|
||||
type: string
|
||||
send_allow:
|
||||
description: addresses, domains or group:<id> (array or list separated by commas, spaces or newlines), max. 500
|
||||
type: array
|
||||
send_block:
|
||||
description: recipients that are always refused, entries as send_allow
|
||||
type: array
|
||||
receive_mode:
|
||||
description: all; internal = senders logged in with a mailbox of the domain plus receive_allow; list = receive_allow only
|
||||
type: string
|
||||
receive_allow:
|
||||
description: entries as send_allow; addresses and domains of this mailcow count for logged-in senders only
|
||||
type: array
|
||||
receive_block:
|
||||
description: senders that are always refused, entries as receive_allow
|
||||
type: array
|
||||
reject_text:
|
||||
description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text
|
||||
type: string
|
||||
mailboxes:
|
||||
description: mailboxes of the domain the policy applies to (replaces the list)
|
||||
type: array
|
||||
groups:
|
||||
description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list)
|
||||
type: array
|
||||
active:
|
||||
type: boolean
|
||||
type: object
|
||||
items:
|
||||
description: contains list of policy IDs you want to update
|
||||
type: object
|
||||
type: object
|
||||
summary: Update mail policies
|
||||
/api/v1/edit/identity-provider-sync:
|
||||
post:
|
||||
responses:
|
||||
@@ -6455,6 +6720,105 @@ paths:
|
||||
may send as through groups.
|
||||
operationId: Get group memberships
|
||||
summary: Get group memberships
|
||||
"/api/v1/get/mail-policy/{id}":
|
||||
get:
|
||||
parameters:
|
||||
- description: id of the policy, or "all" (optionally followed by /domain1,domain2)
|
||||
example: all
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
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: 3
|
||||
domain: example.org
|
||||
name: Trainees
|
||||
description: Trainees only send internally
|
||||
send_mode: internal
|
||||
send_allow:
|
||||
- partner.example
|
||||
send_block: []
|
||||
receive_mode: all
|
||||
receive_allow: []
|
||||
receive_block:
|
||||
- group:8
|
||||
reject_text: ""
|
||||
active: 1
|
||||
mailboxes: []
|
||||
groups:
|
||||
- 7
|
||||
created: "2026-11-07 12:00:00"
|
||||
modified: null
|
||||
can_edit: 1
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Mail policies
|
||||
description: >-
|
||||
Mail policies visible to the caller (admins all, domain admins with the
|
||||
"mail_policies" ACL their domains).
|
||||
operationId: Get mail policies
|
||||
summary: Get mail policies
|
||||
"/api/v1/get/mail-policy/test/{sender}/{recipient}":
|
||||
get:
|
||||
parameters:
|
||||
- description: envelope sender, "-" for the null sender (bounces)
|
||||
example: trainee@example.org
|
||||
in: path
|
||||
name: sender
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- description: recipient; append /login to test a sender logged in with its mailbox
|
||||
example: someone@external.example
|
||||
in: path
|
||||
name: recipient
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
allowed: 0
|
||||
side: send
|
||||
policy: Trainees
|
||||
internal_alias: 0
|
||||
logged_in: 1
|
||||
text: Sending to this recipient is not allowed by the mail policy of the sender
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Mail policies
|
||||
description: >-
|
||||
Would Postfix accept the mail for this recipient? side is "send" or
|
||||
"receive", policy is empty for a policy of another domain,
|
||||
internal_alias = refused by the internal flag of an alias or group.
|
||||
Domain admins test addresses of their domains. Empty answer for invalid
|
||||
input.
|
||||
operationId: Test mail policies
|
||||
summary: Test mail policies
|
||||
/api/v1/get/identity-provider/sync-status:
|
||||
get:
|
||||
responses:
|
||||
@@ -8011,6 +8375,8 @@ tags:
|
||||
description: Manage API keys
|
||||
- name: Groups
|
||||
description: Mailbox groups (distribution lists, send-as rights, templates)
|
||||
- name: Mail policies
|
||||
description: Whom mailboxes and groups may send to and receive from
|
||||
- name: Routing
|
||||
description: Define your own email routes
|
||||
- name: Resources
|
||||
|
||||
@@ -11,6 +11,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/groups.js');
|
||||
$js_minifier->add('/web/js/site/mail-policy.js');
|
||||
$js_minifier->add('/web/js/site/mailbox.js');
|
||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
@@ -45,6 +46,7 @@ $template_data = [
|
||||
'lang_edit' => json_encode($lang['edit']),
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -115,6 +115,17 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
'mbox_templates' => ($_SESSION['mailcow_cc_role'] == 'admin') ? mailbox('get', 'mailbox_templates') : [],
|
||||
];
|
||||
}
|
||||
elseif (isset($_GET['mail-policy']) &&
|
||||
is_numeric($_GET['mail-policy'])) {
|
||||
$mail_policy = \Mailcow\MailPolicy\MailPolicyModule::fromGlobals();
|
||||
$result = $mail_policy->api->getPolicy($_GET['mail-policy']);
|
||||
$template = 'edit/mail-policy.twig';
|
||||
// Choices: mailboxes and groups of the domain of the policy
|
||||
$template_data = [
|
||||
'mailboxes' => !empty($result) ? mailbox('get', 'mailboxes', $result['domain']) : [],
|
||||
'mail_policy_groups' => !empty($result) ? $mail_policy->policies->groupChoices([$result['domain']]) : [],
|
||||
];
|
||||
}
|
||||
elseif (isset($_GET['api-key']) &&
|
||||
is_numeric($_GET['api-key'])) {
|
||||
$result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']);
|
||||
|
||||
@@ -74,6 +74,7 @@ $globalVariables = [
|
||||
'lang_danger' => json_encode($lang['danger']),
|
||||
'lang_syncjobs' => json_encode($lang['syncjobs']),
|
||||
'lang_groups' => json_encode($lang['groups']),
|
||||
'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []),
|
||||
'docker_timeout' => $DOCKER_TIMEOUT,
|
||||
'session_lifetime' => (int)$SESSION_LIFETIME,
|
||||
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
||||
|
||||
@@ -491,6 +491,86 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
// Mail policies: whom the assigned mailboxes and groups may send to and receive from (Mailcow\MailPolicy)
|
||||
"mail_policy" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"domain" => "VARCHAR(255) NOT NULL",
|
||||
"name" => "VARCHAR(100) NOT NULL",
|
||||
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
// all, internal or list; the entries are JSON lists of addresses, domains and group:<id>
|
||||
"send_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
|
||||
"send_allow" => "TEXT NOT NULL",
|
||||
"send_block" => "TEXT NOT NULL",
|
||||
"receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
|
||||
"receive_allow" => "TEXT NOT NULL",
|
||||
"receive_block" => "TEXT NOT NULL",
|
||||
"reject_text" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"domain_name" => array("domain", "name")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_mail_policy_domain" => array(
|
||||
"col" => "domain",
|
||||
"ref" => "domain.domain",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
// a mailbox or a group (its members and its address) a policy is assigned to
|
||||
"mail_policy_member" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"policy_id" => "INT NOT NULL",
|
||||
"username" => "VARCHAR(255) NULL DEFAULT NULL",
|
||||
"group_id" => "INT NULL DEFAULT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"policy_mailbox" => array("policy_id", "username"),
|
||||
"policy_group" => array("policy_id", "group_id")
|
||||
),
|
||||
"key" => array(
|
||||
"username" => array("username"),
|
||||
"group_id" => array("group_id")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_mail_policy_member_policy" => array(
|
||||
"col" => "policy_id",
|
||||
"ref" => "mail_policy.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
),
|
||||
"fk_mail_policy_member_mailbox" => array(
|
||||
"col" => "username",
|
||||
"ref" => "mailbox.username",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
),
|
||||
"fk_mail_policy_member_group" => array(
|
||||
"col" => "group_id",
|
||||
"ref" => "mailbox_group.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"mailbox_group_imap_grant" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
@@ -1053,7 +1133,8 @@ function init_db_schema()
|
||||
"domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"mail_policies" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Controller;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||
use Mailcow\MailPolicy\Service\PolicyService;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php and edit.php (routes `mail-policy`), and the answer to the Postfix policy
|
||||
* service (data/conf/rspamd/dynmaps/mailpolicy.php). Messages are logged as ['mail_policy', action, 'policy', data].
|
||||
*/
|
||||
class MailPolicyApiController
|
||||
{
|
||||
public function __construct(private PolicyService $policies, private PolicyEngine $engine, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** POST add/mail-policy */
|
||||
public function addPolicy(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', $attr, function (array $log) use ($attr): bool {
|
||||
$policy = $this->policies->create($attr);
|
||||
return $this->response->success(['mail_policy_added', htmlspecialchars($policy->name)], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/mail-policy: one message per id */
|
||||
public function editPolicies(?array $items, array $attr): bool
|
||||
{
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||
$this->policies->requireEditor();
|
||||
return $this->batch((array)($data['id'] ?? []), $log,
|
||||
fn(int $id): array => ['mail_policy_modified', htmlspecialchars($this->policies->update($id, $attr)->name)]);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST delete/mail-policy: one message per id */
|
||||
public function deletePolicies(array $items): bool
|
||||
{
|
||||
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||
$this->policies->requireEditor();
|
||||
return $this->batch($items, $log, fn(int $id): array => ['mail_policy_removed', htmlspecialchars($this->policies->delete($id)->name)]);
|
||||
});
|
||||
}
|
||||
|
||||
/** GET mail-policy/all[/<domain,domain>] */
|
||||
public function getPolicies(?string $domains = null): array
|
||||
{
|
||||
$list = array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen'));
|
||||
return array_values(array_filter(array_map(fn(int $id): array => $this->getPolicy($id), $this->policies->visibleIds($list))));
|
||||
}
|
||||
|
||||
/** GET mail-policy/<id>, also edit.php */
|
||||
public function getPolicy(mixed $id): array
|
||||
{
|
||||
$id = $this->toId($id);
|
||||
$policy = ($id === null) ? null : $this->policies->get($id);
|
||||
return ($policy === null) ? [] : $this->policies->toArray($policy);
|
||||
}
|
||||
|
||||
/** GET mail-policy/test/<sender>/<recipient>[/login]: would the mail be accepted? Sender "-" = null sender; [] when not allowed to ask */
|
||||
public function test(string $sender, string $recipient, bool $loggedIn): array
|
||||
{
|
||||
try {
|
||||
return $this->policies->test(($sender === '-') ? '' : $sender, $recipient, $loggedIn);
|
||||
} catch (UserError) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Answer for the Postfix policy service: DUNNO or the rejection */
|
||||
public function check(string $sender, string $recipient, string $login, int $port): string
|
||||
{
|
||||
return $this->engine->check($sender, $recipient, $login, $port)->action();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = ['mail_policy', $action, 'policy', $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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy;
|
||||
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\MailPolicy\Controller\MailPolicyApiController;
|
||||
use Mailcow\MailPolicy\Repository\LookupRepository;
|
||||
use Mailcow\MailPolicy\Repository\PolicyRepository;
|
||||
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||
use Mailcow\MailPolicy\Service\PolicyService;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the mail policies: whom mailboxes and groups may send to and receive from, checked by
|
||||
* Postfix at RCPT TO through the policy service (mailcow-policy in master.cf, data/conf/rspamd/dynmaps/mailpolicy.php).
|
||||
*
|
||||
* process_get_return(MailPolicyModule::fromGlobals()->api->getPolicies());
|
||||
* echo MailPolicyModule::create($pdo, Context::system())->api->check($sender, $recipient, $login, $port); // policy service
|
||||
*/
|
||||
class MailPolicyModule
|
||||
{
|
||||
public function __construct(
|
||||
public readonly PolicyService $policies,
|
||||
public readonly PolicyEngine $engine,
|
||||
public readonly MailPolicyApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
public static function create(PDO $pdo, Context $context): self
|
||||
{
|
||||
$engine = new PolicyEngine(new LookupRepository($pdo));
|
||||
$policies = new PolicyService(new PolicyRepository($pdo), $engine, $context);
|
||||
return new self($policies, $engine, new MailPolicyApiController($policies, $engine, new ApiResponse()));
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Model;
|
||||
|
||||
/**
|
||||
* Result of the policy check for one recipient: allowed, or refused by the send or receive part of a policy.
|
||||
*/
|
||||
class Decision
|
||||
{
|
||||
private const TEXT = [
|
||||
'send' => 'Sending to this recipient is not allowed by the mail policy of the sender',
|
||||
'receive' => 'This recipient does not accept mail from this sender',
|
||||
];
|
||||
|
||||
private function __construct(public readonly bool $allowed, public readonly string $side = '', public readonly ?Policy $policy = null)
|
||||
{
|
||||
}
|
||||
|
||||
public static function allow(): self
|
||||
{
|
||||
return new self(true);
|
||||
}
|
||||
|
||||
/** $side send or receive */
|
||||
public static function deny(string $side, Policy $policy): self
|
||||
{
|
||||
return new self(false, $side, $policy);
|
||||
}
|
||||
|
||||
/** Text of the rejection: the text of the policy or the default */
|
||||
public function text(): string
|
||||
{
|
||||
return ($this->policy !== null && $this->policy->rejectText !== '') ? $this->policy->rejectText : (self::TEXT[$this->side] ?? '');
|
||||
}
|
||||
|
||||
/** Answer for the Postfix policy protocol */
|
||||
public function action(): string
|
||||
{
|
||||
return $this->allowed ? 'DUNNO' : '550 5.7.1 ' . $this->text();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Model;
|
||||
|
||||
/**
|
||||
* A mail policy (table `mail_policy`): whom its mailboxes and groups may send to and receive from.
|
||||
*
|
||||
* Each direction has a mode: `all` (only the blocked entries are refused), `internal` (the own domain and its
|
||||
* alias domains, plus the allowed entries) or `list` (only the allowed entries). Blocked entries are always
|
||||
* refused. Entries are addresses, domains or `group:<id>`. A sender counts as internal only when it is logged in
|
||||
* to this mailcow (SASL) with a mailbox of the domain, never because of its address.
|
||||
*/
|
||||
class Policy
|
||||
{
|
||||
public const MODES = ['all', 'internal', 'list'];
|
||||
|
||||
public ?int $id = null;
|
||||
public string $domain = '';
|
||||
public string $name = '';
|
||||
public string $description = '';
|
||||
public string $sendMode = 'all';
|
||||
/** @var string[] */
|
||||
public array $sendAllow = [];
|
||||
/** @var string[] */
|
||||
public array $sendBlock = [];
|
||||
public string $receiveMode = 'all';
|
||||
/** @var string[] */
|
||||
public array $receiveAllow = [];
|
||||
/** @var string[] */
|
||||
public array $receiveBlock = [];
|
||||
/** Text of the rejection, '' = default text */
|
||||
public string $rejectText = '';
|
||||
public int $active = 1;
|
||||
/** @var string[] usernames the policy is assigned to */
|
||||
public array $mailboxes = [];
|
||||
/** @var int[] groups the policy is assigned to: their members and their address */
|
||||
public array $groups = [];
|
||||
public ?string $created = null;
|
||||
public ?string $modified = null;
|
||||
|
||||
/** Row of `mail_policy`; assignments are set by the repository */
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
$policy = new self();
|
||||
$policy->id = (int)$row['id'];
|
||||
$policy->domain = (string)$row['domain'];
|
||||
$policy->name = (string)$row['name'];
|
||||
$policy->description = (string)($row['description'] ?? '');
|
||||
$policy->sendMode = (string)$row['send_mode'];
|
||||
$policy->sendAllow = self::list($row['send_allow'] ?? null);
|
||||
$policy->sendBlock = self::list($row['send_block'] ?? null);
|
||||
$policy->receiveMode = (string)$row['receive_mode'];
|
||||
$policy->receiveAllow = self::list($row['receive_allow'] ?? null);
|
||||
$policy->receiveBlock = self::list($row['receive_block'] ?? null);
|
||||
$policy->rejectText = (string)($row['reject_text'] ?? '');
|
||||
$policy->active = (int)$row['active'];
|
||||
$policy->created = $row['created'] ?? null;
|
||||
$policy->modified = $row['modified'] ?? null;
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** Column values for `mail_policy` */
|
||||
public function toRow(): array
|
||||
{
|
||||
return [
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'send_mode' => $this->sendMode,
|
||||
'send_allow' => json_encode($this->sendAllow),
|
||||
'send_block' => json_encode($this->sendBlock),
|
||||
'receive_mode' => $this->receiveMode,
|
||||
'receive_allow' => json_encode($this->receiveAllow),
|
||||
'receive_block' => json_encode($this->receiveBlock),
|
||||
'reject_text' => $this->rejectText,
|
||||
'active' => $this->active,
|
||||
];
|
||||
}
|
||||
|
||||
/** Entry of the API and the edit page */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'description' => $this->description,
|
||||
'send_mode' => $this->sendMode,
|
||||
'send_allow' => $this->sendAllow,
|
||||
'send_block' => $this->sendBlock,
|
||||
'receive_mode' => $this->receiveMode,
|
||||
'receive_allow' => $this->receiveAllow,
|
||||
'receive_block' => $this->receiveBlock,
|
||||
'reject_text' => $this->rejectText,
|
||||
'active' => $this->active,
|
||||
'mailboxes' => $this->mailboxes,
|
||||
'groups' => $this->groups,
|
||||
'created' => $this->created,
|
||||
'modified' => $this->modified,
|
||||
];
|
||||
}
|
||||
|
||||
/** @return string[] entries of a JSON list column */
|
||||
private static function list(mixed $json): array
|
||||
{
|
||||
$list = is_string($json) ? json_decode($json, true) : null;
|
||||
return is_array($list) ? array_values(array_filter($list, 'is_string')) : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Repository;
|
||||
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
|
||||
/**
|
||||
* Read-only lookups of the policy check: mailboxes, alias domains, groups and the active policies of a mailbox
|
||||
* or group. Addresses are passed in lower case.
|
||||
*/
|
||||
class LookupRepository extends Repository
|
||||
{
|
||||
/** @return array{username: string, domain: string}|null a regular mailbox that is not disabled */
|
||||
public function mailbox(string $username): ?array
|
||||
{
|
||||
$row = $this->query("SELECT `username`, `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = '' AND `active` != '0'",
|
||||
['username' => $username])->fetch();
|
||||
return is_array($row) ? ['username' => strtolower((string)$row['username']), 'domain' => strtolower((string)$row['domain'])] : null;
|
||||
}
|
||||
|
||||
/** Target domain of an active alias domain */
|
||||
public function aliasDomainTarget(string $domain): ?string
|
||||
{
|
||||
$target = $this->query("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'",
|
||||
['domain' => $domain])->fetchColumn();
|
||||
return is_string($target) ? strtolower($target) : null;
|
||||
}
|
||||
|
||||
/** A domain or alias domain of this mailcow */
|
||||
public function isLocalDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :d1 UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d2",
|
||||
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Active group with this distribution address */
|
||||
public function groupByAddress(string $address): ?int
|
||||
{
|
||||
$id = $this->query("SELECT `id` FROM `mailbox_group` WHERE `address` = :address AND `active` = '1'", ['address' => $address])->fetchColumn();
|
||||
return ($id === false) ? null : (int)$id;
|
||||
}
|
||||
|
||||
public function groupAddress(int $id): ?string
|
||||
{
|
||||
$address = $this->query("SELECT `address` FROM `mailbox_group` WHERE `id` = :id AND `active` = '1'", ['id' => $id])->fetchColumn();
|
||||
return is_string($address) && $address !== '' ? strtolower($address) : null;
|
||||
}
|
||||
|
||||
public function isGroupMember(int $groupId, string $username): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` WHERE `group_id` = :id AND `username` = :username",
|
||||
['id' => $groupId, 'username' => $username])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Domain of an active alias that only accepts mail from its own domain (alias and group flag `internal`) */
|
||||
public function internalAlias(string $address): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` = '1'",
|
||||
['address' => $address])->fetchColumn();
|
||||
return is_string($domain) ? strtolower($domain) : null;
|
||||
}
|
||||
|
||||
/** @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of */
|
||||
public function policiesForMailbox(string $username): array
|
||||
{
|
||||
return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p`
|
||||
JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||
WHERE `p`.`active` = '1' AND (`pm`.`username` = :u1 OR `pm`.`group_id` IN (
|
||||
SELECT `gm`.`group_id` FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id`
|
||||
WHERE `gm`.`username` = :u2 AND `g`.`active` = '1'))
|
||||
ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username]);
|
||||
}
|
||||
|
||||
/** @return Policy[] active policies assigned to the group */
|
||||
public function policiesForGroup(int $groupId): array
|
||||
{
|
||||
return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p`
|
||||
JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||
WHERE `p`.`active` = '1' AND `pm`.`group_id` = :id
|
||||
ORDER BY `p`.`id`", ['id' => $groupId]);
|
||||
}
|
||||
|
||||
/** @return Policy[] */
|
||||
private function policies(string $sql, array $params): array
|
||||
{
|
||||
return array_map(fn(array $row): Policy => Policy::fromRow($row), $this->query($sql, $params)->fetchAll());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Repository;
|
||||
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Repository;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Tables `mail_policy` and `mail_policy_member` (the mailboxes and groups a policy is assigned to), plus the
|
||||
* lookups the editing needs. Assignments go with their mailbox, group or domain (foreign keys, ON DELETE CASCADE).
|
||||
*/
|
||||
class PolicyRepository extends Repository
|
||||
{
|
||||
public function find(int $id): ?Policy
|
||||
{
|
||||
$row = $this->query("SELECT * FROM `mail_policy` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||
return is_array($row) ? $this->withMembers(Policy::fromRow($row)) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $domains limit to these domains, [] = all visible
|
||||
* @return int[] ids of the policies visible to the caller, ordered by domain and name
|
||||
*/
|
||||
public function visibleIds(Context $context, array $domains = []): array
|
||||
{
|
||||
if ($context->hasAdminRights()) {
|
||||
[$visible, $params] = ['1=1', []];
|
||||
} elseif ($context->isDomainAdmin()) {
|
||||
[$visible, $params] = ["`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)",
|
||||
['vis_da' => $context->username]];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
$sql = "SELECT `id` FROM `mail_policy` WHERE $visible";
|
||||
if (!empty($domains)) {
|
||||
$names = [];
|
||||
foreach (array_values($domains) as $i => $domain) {
|
||||
$names[] = ':dom' . $i;
|
||||
$params['dom' . $i] = $domain;
|
||||
}
|
||||
$sql .= " AND `domain` IN (" . implode(', ', $names) . ")";
|
||||
}
|
||||
return array_map('intval', $this->query($sql . " ORDER BY `domain`, `name`", $params)->fetchAll(PDO::FETCH_COLUMN));
|
||||
}
|
||||
|
||||
public function nameExists(string $domain, string $name, ?int $exceptId = null): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mail_policy` WHERE `domain` = :domain AND `name` = :name AND `id` != :id",
|
||||
['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Inserts or updates the policy with its assignments; returns its id */
|
||||
public function save(Policy $policy): int
|
||||
{
|
||||
return $this->transaction(function () use ($policy): int {
|
||||
if ($policy->id === null) {
|
||||
$policy->id = $this->insertRow('mail_policy', $policy->toRow());
|
||||
} else {
|
||||
$this->updateRow('mail_policy', $policy->id, $policy->toRow());
|
||||
$this->query("DELETE FROM `mail_policy_member` WHERE `policy_id` = :id", ['id' => $policy->id]);
|
||||
}
|
||||
foreach ($policy->mailboxes as $username) {
|
||||
$this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'username' => $username]);
|
||||
}
|
||||
foreach ($policy->groups as $groupId) {
|
||||
$this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'group_id' => $groupId]);
|
||||
}
|
||||
return $policy->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(int $id): void
|
||||
{
|
||||
$this->query("DELETE FROM `mail_policy` WHERE `id` = :id", ['id' => $id]);
|
||||
}
|
||||
|
||||
/** A primary domain (policies never belong to alias domains) */
|
||||
public function isDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** Domain of a regular mailbox, null if there is none */
|
||||
public function mailboxDomain(string $username): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = ''", ['username' => $username])->fetchColumn();
|
||||
return is_string($domain) ? $domain : null;
|
||||
}
|
||||
|
||||
public function groupDomain(int $id): ?string
|
||||
{
|
||||
$domain = $this->query("SELECT `domain` FROM `mailbox_group` WHERE `id` = :id", ['id' => $id])->fetchColumn();
|
||||
return is_string($domain) ? $domain : null;
|
||||
}
|
||||
|
||||
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||
public function groupsIn(array $domains): array
|
||||
{
|
||||
if (empty($domains)) {
|
||||
return [];
|
||||
}
|
||||
$names = [];
|
||||
$params = [];
|
||||
foreach (array_values($domains) as $i => $domain) {
|
||||
$names[] = ':dom' . $i;
|
||||
$params['dom' . $i] = $domain;
|
||||
}
|
||||
return $this->query("SELECT `id`, `name`, `domain`, `address` FROM `mailbox_group` WHERE `domain` IN (" . implode(', ', $names) . ")
|
||||
ORDER BY `domain`, `name`", $params)->fetchAll();
|
||||
}
|
||||
|
||||
private function withMembers(Policy $policy): Policy
|
||||
{
|
||||
foreach ($this->query("SELECT `username`, `group_id` FROM `mail_policy_member` WHERE `policy_id` = :id ORDER BY `username`, `group_id`",
|
||||
['id' => $policy->id])->fetchAll() as $row) {
|
||||
if ($row['username'] !== null) {
|
||||
$policy->mailboxes[] = (string)$row['username'];
|
||||
} elseif ($row['group_id'] !== null) {
|
||||
$policy->groups[] = (int)$row['group_id'];
|
||||
}
|
||||
}
|
||||
return $policy;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Service;
|
||||
|
||||
use Mailcow\MailPolicy\Model\Decision;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use Mailcow\MailPolicy\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Policy check of one recipient, asked by Postfix at RCPT TO (data/conf/rspamd/dynmaps/mailpolicy.php).
|
||||
*
|
||||
* Send part: the policies of the logged-in mailbox (assigned to it or to its groups) and of the mailbox the
|
||||
* sender address belongs to. Receive part: the policies of the addressed mailbox (+tag and alias domains
|
||||
* resolved), of the addressed group and the `internal` flag of the addressed alias or group. Mail sent to an
|
||||
* alias or group reaches its members without their own policies. Every policy must allow the mail.
|
||||
*
|
||||
* Internal: a recipient in the domain of the policy or one of its alias domains; a sender logged in with a
|
||||
* mailbox of that domain. Entries of a local domain or address count for logged-in senders only, so they cannot
|
||||
* be forged; external entries are compared with the envelope sender. Dovecot submits Sieve redirects and
|
||||
* vacation replies of a mailbox without login on port 588: they count as sent by that mailbox.
|
||||
*/
|
||||
class PolicyEngine
|
||||
{
|
||||
/** Ports of mailcow's own deliveries: quarantine release (590), BCC and archive copies (591) */
|
||||
private const INTERNAL_PORTS = [590, 591];
|
||||
/** Submission of SOGo and Dovecot, without login reachable from mailcow's own network only */
|
||||
private const SERVICE_PORT = 588;
|
||||
|
||||
public function __construct(private LookupRepository $lookup)
|
||||
{
|
||||
}
|
||||
|
||||
/** $login: SASL username, '' if not logged in; $port: smtpd port of the request, 0 if unknown */
|
||||
public function check(string $sender, string $recipient, string $login, int $port = 0): Decision
|
||||
{
|
||||
$sender = self::address($sender);
|
||||
$recipient = self::address($recipient);
|
||||
$login = self::address($login);
|
||||
if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '') {
|
||||
return Decision::allow();
|
||||
}
|
||||
$account = ($login !== '') ? $this->lookup->mailbox($login) : null;
|
||||
if ($login === '' && $port === self::SERVICE_PORT) {
|
||||
// Sieve redirects and vacation replies use the address of the mailbox, other mail of mailcow passes
|
||||
$account = $this->lookup->mailbox($this->resolve(self::untagged($sender)));
|
||||
if ($account === null) {
|
||||
return Decision::allow();
|
||||
}
|
||||
}
|
||||
foreach ($this->sendingPolicies($account, $sender) as $policy) {
|
||||
if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain),
|
||||
fn(string $entry): bool => $this->recipientMatches($entry, $recipient))) {
|
||||
return Decision::deny('send', $policy);
|
||||
}
|
||||
}
|
||||
foreach ($this->receivingPolicies($recipient) as $policy) {
|
||||
if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock,
|
||||
$account !== null && $account['domain'] === $policy->domain,
|
||||
fn(string $entry): bool => $this->senderMatches($entry, $sender, $account))) {
|
||||
return Decision::deny('receive', $policy);
|
||||
}
|
||||
}
|
||||
return Decision::allow();
|
||||
}
|
||||
|
||||
/** Blocked entries first; then the mode decides */
|
||||
private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool
|
||||
{
|
||||
foreach ($block as $entry) {
|
||||
if ($matches($entry)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return match ($mode) {
|
||||
'internal' => $internal || $this->any($allow, $matches),
|
||||
'list' => $this->any($allow, $matches),
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
private function any(array $entries, callable $matches): bool
|
||||
{
|
||||
foreach ($entries as $entry) {
|
||||
if ($matches($entry)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return Policy[] of the logged-in mailbox and of the mailbox it sends as */
|
||||
private function sendingPolicies(?array $account, string $sender): array
|
||||
{
|
||||
if ($account === null) {
|
||||
return [];
|
||||
}
|
||||
$policies = $this->lookup->policiesForMailbox($account['username']);
|
||||
$sentAs = $this->lookup->mailbox($this->resolve(self::untagged($sender)));
|
||||
if ($sentAs !== null && $sentAs['username'] !== $account['username']) {
|
||||
$policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username']));
|
||||
}
|
||||
return self::unique($policies);
|
||||
}
|
||||
|
||||
/** @return Policy[] of the addressed mailbox, group and internal alias */
|
||||
private function receivingPolicies(string $recipient): array
|
||||
{
|
||||
$address = $this->resolve(self::untagged($recipient));
|
||||
$policies = [];
|
||||
if ($this->lookup->mailbox($address) !== null) {
|
||||
$policies = $this->lookup->policiesForMailbox($address);
|
||||
}
|
||||
$group = $this->lookup->groupByAddress($address);
|
||||
if ($group !== null) {
|
||||
$policies = array_merge($policies, $this->lookup->policiesForGroup($group));
|
||||
}
|
||||
$aliasDomain = $this->lookup->internalAlias($address);
|
||||
if ($aliasDomain !== null) {
|
||||
// the `internal` flag of an alias or group: only senders of its domain
|
||||
$internal = new Policy();
|
||||
$internal->domain = $aliasDomain;
|
||||
$internal->receiveMode = 'internal';
|
||||
$policies[] = $internal;
|
||||
}
|
||||
return self::unique($policies);
|
||||
}
|
||||
|
||||
private function isInternalRecipient(string $recipient, string $domain): bool
|
||||
{
|
||||
return $this->resolveDomain(self::domainOf($recipient)) === $domain;
|
||||
}
|
||||
|
||||
private function recipientMatches(string $entry, string $recipient): bool
|
||||
{
|
||||
$address = $this->resolve(self::untagged($recipient));
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = (int)substr($entry, 6);
|
||||
return $this->lookup->groupAddress($id) === $address
|
||||
|| ($this->lookup->mailbox($address) !== null && $this->lookup->isGroupMember($id, $address));
|
||||
}
|
||||
if (str_contains($entry, '@')) {
|
||||
return $this->resolve($entry) === $address;
|
||||
}
|
||||
return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry);
|
||||
}
|
||||
|
||||
private function senderMatches(string $entry, string $sender, ?array $account): bool
|
||||
{
|
||||
$address = $this->resolve(self::untagged($sender));
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = (int)substr($entry, 6);
|
||||
return $account !== null && ($this->lookup->isGroupMember($id, $account['username']) || $this->lookup->groupAddress($id) === $address);
|
||||
}
|
||||
if (str_contains($entry, '@')) {
|
||||
// a local address only for a logged-in sender (Postfix checked that the login may use it)
|
||||
return $this->lookup->isLocalDomain(self::domainOf($entry))
|
||||
? $account !== null && ($account['username'] === $this->resolve($entry) || $address === $this->resolve($entry))
|
||||
: self::untagged($sender) === $entry;
|
||||
}
|
||||
return $this->lookup->isLocalDomain($entry)
|
||||
? $account !== null && ($account['domain'] === $this->resolveDomain($entry) || $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry))
|
||||
: self::domainOf($sender) === $entry;
|
||||
}
|
||||
|
||||
/** Address in an alias domain as the address in its target domain */
|
||||
private function resolve(string $address): string
|
||||
{
|
||||
$domain = self::domainOf($address);
|
||||
$target = ($domain !== '') ? $this->lookup->aliasDomainTarget($domain) : null;
|
||||
return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target;
|
||||
}
|
||||
|
||||
private function resolveDomain(string $domain): string
|
||||
{
|
||||
return ($domain === '') ? '' : ($this->lookup->aliasDomainTarget($domain) ?? $domain);
|
||||
}
|
||||
|
||||
/** @param Policy[] $policies @return Policy[] without repeated policies */
|
||||
private static function unique(array $policies): array
|
||||
{
|
||||
$unique = [];
|
||||
foreach ($policies as $policy) {
|
||||
$unique[$policy->id ?? spl_object_id($policy)] = $policy;
|
||||
}
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
/** Lower case address without angle brackets; '' unless local@domain */
|
||||
private static function address(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value, " \t\r\n<>"));
|
||||
return preg_match('/^[^@\s,<>]+@[^@\s,<>]+$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
private static function untagged(string $address): string
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
$pos = ($at === false) ? false : strpos(substr($address, 0, $at), '+');
|
||||
return ($pos === false || $pos === 0) ? $address : substr($address, 0, $pos) . substr($address, $at);
|
||||
}
|
||||
|
||||
private static function domainOf(string $address): string
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
return ($at === false) ? '' : substr($address, $at + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use Mailcow\MailPolicy\Repository\PolicyRepository;
|
||||
|
||||
/**
|
||||
* Use cases of mail policies.
|
||||
*
|
||||
* Read: admins all policies (also read-only API keys); domain admins with the `mail_policies` ACL the policies
|
||||
* of their domains.
|
||||
* Edit: admins; domain admins with the ACL for policies of their domains. A policy is assigned to mailboxes and
|
||||
* groups of its own domain; `group:<id>` entries name groups the editor may access.
|
||||
*/
|
||||
class PolicyService
|
||||
{
|
||||
private const MAX_ENTRIES = 500;
|
||||
/** The reject text goes into the SMTP reply: printable ASCII */
|
||||
private const MAX_REJECT_TEXT = 200;
|
||||
private const LISTS = ['send_allow' => 'sendAllow', 'send_block' => 'sendBlock', 'receive_allow' => 'receiveAllow', 'receive_block' => 'receiveBlock'];
|
||||
|
||||
public function __construct(private PolicyRepository $repo, private PolicyEngine $engine, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
public function canRead(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights()
|
||||
|| ($this->context->isDomainAdmin() && $this->context->hasAcl('mail_policies'));
|
||||
}
|
||||
|
||||
/** @throws AccessDenied unless the caller may edit policies at all */
|
||||
public function requireEditor(): void
|
||||
{
|
||||
if (!$this->canRead() || $this->context->isReadOnlyApi()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
|
||||
public function get(int $id): ?Policy
|
||||
{
|
||||
$policy = $this->canRead() ? $this->repo->find($id) : null;
|
||||
return ($policy !== null && $this->visible($policy)) ? $policy : null;
|
||||
}
|
||||
|
||||
/** @return int[] */
|
||||
public function visibleIds(array $domains = []): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : [];
|
||||
}
|
||||
|
||||
public function canEdit(Policy $policy): bool
|
||||
{
|
||||
return $this->canRead() && !$this->context->isReadOnlyApi() && $this->visible($policy);
|
||||
}
|
||||
|
||||
public function toArray(Policy $policy): array
|
||||
{
|
||||
return $policy->toArray() + ['can_edit' => (int)$this->canEdit($policy)];
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function create(array $input): Policy
|
||||
{
|
||||
$this->requireEditor();
|
||||
$policy = new Policy();
|
||||
$policy->domain = $this->domainFor($input['domain'] ?? null);
|
||||
$this->apply($policy, $input);
|
||||
$this->repo->save($policy);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** Attributes missing in $input keep their value @throws UserError */
|
||||
public function update(int $id, array $input): Policy
|
||||
{
|
||||
$policy = $this->editable($id);
|
||||
$this->apply($policy, $input);
|
||||
$this->repo->save($policy);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
public function delete(int $id): Policy
|
||||
{
|
||||
$policy = $this->editable($id);
|
||||
$this->repo->delete($id);
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||
public function groupChoices(array $domains): array
|
||||
{
|
||||
return $this->canRead() ? $this->repo->groupsIn($domains) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* "Test a rule": would the mail be accepted? A sender that is a mailbox counts as logged in when $loggedIn.
|
||||
*
|
||||
* @return array{allowed: int, side: string, policy: ?string, internal_alias: int, text: string}
|
||||
* @throws UserError
|
||||
*/
|
||||
public function test(mixed $sender, mixed $recipient, bool $loggedIn): array
|
||||
{
|
||||
if (!$this->canRead()) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$sender = strtolower((string)Input::text($sender));
|
||||
$recipient = strtolower((string)Input::text($recipient));
|
||||
if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new UserError('mail_policy_test_invalid');
|
||||
}
|
||||
// domain admins test mail of their domains
|
||||
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($sender))
|
||||
&& !$this->context->canAccessDomain(self::domainOf($recipient))) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$login = ($loggedIn && $this->repo->mailboxDomain($sender) !== null) ? $sender : '';
|
||||
$decision = $this->engine->check($sender, $recipient, $login);
|
||||
$policy = $decision->policy;
|
||||
return [
|
||||
'allowed' => (int)$decision->allowed,
|
||||
'side' => $decision->side,
|
||||
'policy' => ($policy !== null && $policy->id !== null && $this->visible($policy)) ? $policy->name : null,
|
||||
'internal_alias' => (int)($policy !== null && $policy->id === null),
|
||||
'logged_in' => (int)($login !== ''),
|
||||
'text' => $decision->allowed ? '' : $decision->text(),
|
||||
];
|
||||
}
|
||||
|
||||
private function visible(Policy $policy): bool
|
||||
{
|
||||
return $this->context->hasAdminRights() || $this->context->canAccessDomain($policy->domain);
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function editable(int $id): Policy
|
||||
{
|
||||
$this->requireEditor();
|
||||
$policy = $this->get($id);
|
||||
if ($policy === null || !$this->canEdit($policy)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function domainFor(mixed $value): string
|
||||
{
|
||||
$domain = strtolower((string)Input::text($value));
|
||||
if ($domain === '' || !$this->repo->isDomain($domain)) {
|
||||
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
|
||||
}
|
||||
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/** @throws UserError */
|
||||
private function apply(Policy $policy, array $input): void
|
||||
{
|
||||
if (array_key_exists('name', $input) || $policy->id === null) {
|
||||
$name = Input::text($input['name'] ?? null);
|
||||
if ($name === null || $name === '' || mb_strlen($name) > 100 || preg_match('/[\x00-\x1f\x7f]/', $name)) {
|
||||
throw new UserError('mail_policy_name_invalid');
|
||||
}
|
||||
if ($this->repo->nameExists($policy->domain, $name, $policy->id)) {
|
||||
throw new UserError('object_exists', [htmlspecialchars($name)]);
|
||||
}
|
||||
$policy->name = $name;
|
||||
}
|
||||
if (array_key_exists('description', $input)) {
|
||||
$description = Input::text($input['description']);
|
||||
if ($description === null || mb_strlen($description) > 255 || preg_match('/[\x00-\x1f\x7f]/', $description)) {
|
||||
throw new UserError('mail_policy_description_invalid');
|
||||
}
|
||||
$policy->description = $description;
|
||||
}
|
||||
foreach (['send_mode' => 'sendMode', 'receive_mode' => 'receiveMode'] as $key => $property) {
|
||||
if (array_key_exists($key, $input)) {
|
||||
$mode = Input::text($input[$key]);
|
||||
$policy->$property = in_array($mode, Policy::MODES, true) ? $mode : throw new UserError('mail_policy_mode_invalid');
|
||||
}
|
||||
}
|
||||
foreach (self::LISTS as $key => $property) {
|
||||
// the UI sends the groups of a list separately (<list>_groups); the API may name them as group:<id>
|
||||
if (array_key_exists($key, $input) || array_key_exists($key . '_groups', $input)) {
|
||||
$entries = array_key_exists($key, $input) ? self::values($input[$key])
|
||||
: array_filter($policy->$property, fn(string $entry): bool => !str_starts_with($entry, 'group:'));
|
||||
if (array_key_exists($key . '_groups', $input)) {
|
||||
$entries = array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:'));
|
||||
$entries = array_merge($entries, array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups'])));
|
||||
}
|
||||
$policy->$property = $this->entries(array_values($entries));
|
||||
}
|
||||
}
|
||||
if (array_key_exists('reject_text', $input)) {
|
||||
$text = Input::text($input['reject_text']);
|
||||
if ($text === null || strlen($text) > self::MAX_REJECT_TEXT || preg_match('/[^\x20-\x7e]/', $text)) {
|
||||
throw new UserError('mail_policy_reject_text_invalid');
|
||||
}
|
||||
$policy->rejectText = $text;
|
||||
}
|
||||
if (array_key_exists('active', $input)) {
|
||||
$policy->active = (int)self::flag($input['active']);
|
||||
}
|
||||
if (array_key_exists('mailboxes', $input)) {
|
||||
$policy->mailboxes = [];
|
||||
foreach (self::values($input['mailboxes']) as $username) {
|
||||
$username = strtolower($username);
|
||||
if ($this->repo->mailboxDomain($username) !== $policy->domain) {
|
||||
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]);
|
||||
}
|
||||
$policy->mailboxes[$username] = $username;
|
||||
}
|
||||
$policy->mailboxes = array_values($policy->mailboxes);
|
||||
}
|
||||
if (array_key_exists('groups', $input)) {
|
||||
$policy->groups = [];
|
||||
foreach (self::values($input['groups']) as $value) {
|
||||
$id = Input::intInRange($value, 1, PHP_INT_MAX);
|
||||
if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) {
|
||||
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]);
|
||||
}
|
||||
$policy->groups[$id] = $id;
|
||||
}
|
||||
$policy->groups = array_values($policy->groups);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entries of a list: addresses, domains (a leading @ is dropped) and group:<id>, lower case, without repeats.
|
||||
*
|
||||
* @return string[]
|
||||
* @throws UserError
|
||||
*/
|
||||
private function entries(mixed $value): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach (self::values($value) as $entry) {
|
||||
$entry = strtolower($entry);
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX);
|
||||
$domain = ($id === null) ? null : $this->repo->groupDomain($id);
|
||||
if ($domain === null || (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain))) {
|
||||
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||
}
|
||||
$entry = 'group:' . $id;
|
||||
} elseif (str_contains(ltrim($entry, '@'), '@')) {
|
||||
if (!filter_var($entry, FILTER_VALIDATE_EMAIL)) {
|
||||
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||
}
|
||||
} else {
|
||||
$entry = ltrim($entry, '@');
|
||||
if (!str_contains($entry, '.') || !filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
|
||||
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||
}
|
||||
}
|
||||
$entries[$entry] = $entry;
|
||||
}
|
||||
if (count($entries) > self::MAX_ENTRIES) {
|
||||
throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
|
||||
}
|
||||
return array_values($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
private static function values(mixed $value): array
|
||||
{
|
||||
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', (string)Input::text($value));
|
||||
return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen'));
|
||||
}
|
||||
|
||||
/** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */
|
||||
private static function flag(mixed $value): bool
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$value = end($value);
|
||||
}
|
||||
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
private static function domainOf(string $address): string
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
return ($at === false) ? '' : substr($address, $at + 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Mail policies (admin/domainadmin mailbox page): list, domain choices of the add modal, test of a rule. Loaded via $js_minifier.
|
||||
var mailcowMailPolicy = (function($) {
|
||||
'use strict';
|
||||
|
||||
// lang.mail_policy of the mailbox page, '' if missing
|
||||
function L(key) {
|
||||
var src = (typeof lang !== 'undefined' && lang && lang.mail_policy) ? lang.mail_policy : {};
|
||||
return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
|
||||
}
|
||||
function esc(s) {
|
||||
return escapeHtml(s === null || s === undefined ? '' : s);
|
||||
}
|
||||
|
||||
// mode of one direction with the number of allowed and blocked entries
|
||||
function sideCell(mode, allow, block) {
|
||||
var html = esc(L('summary_' + mode));
|
||||
var counts = [];
|
||||
if (mode !== 'all' && allow.length) counts.push('+' + allow.length + ' ' + esc(L('summary_allowed')));
|
||||
if (block.length) counts.push(block.length + ' ' + esc(L('summary_blocked')));
|
||||
return html + (counts.length ? '<div class="small text-muted">' + counts.join(', ') + '</div>' : '');
|
||||
}
|
||||
|
||||
function assignedCell(item) {
|
||||
var list = (item.mailboxes || []).slice(0, 3).map(esc);
|
||||
if ((item.mailboxes || []).length > 3) list.push('<span class="text-muted">+' + (item.mailboxes.length - 3) + '</span>');
|
||||
if ((item.groups || []).length) list.push('<i class="bi bi-people"></i> ' + esc(L('groups')) + ': ' + item.groups.length);
|
||||
return list.length ? list.join('<br>') : '<span class="text-muted">' + esc(L('assigned_none')) + '</span>';
|
||||
}
|
||||
|
||||
function drawTable() {
|
||||
if ($.fn.DataTable.isDataTable('#mail_policy_table')) {
|
||||
$('#mail_policy_table').DataTable().columns.adjust().responsive.recalc();
|
||||
return;
|
||||
}
|
||||
$('#mail_policy_table').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,
|
||||
order: [[3, 'asc']],
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/mail-policy/all",
|
||||
dataSrc: function(json) {
|
||||
if (!Array.isArray(json)) return [];
|
||||
$.each(json, function(i, item) {
|
||||
item.chkbox = item.can_edit ?
|
||||
'<input type="checkbox" class="form-check-input" data-id="mail_policy" name="multi_select" value="' + encodeURIComponent(item.id) + '" />' :
|
||||
'<input type="checkbox" class="form-check-input" disabled />';
|
||||
item.name_display = esc(item.name) + (item.description ? '<div class="small text-muted">' + esc(item.description) + '</div>' : '');
|
||||
item.send_display = sideCell(item.send_mode, item.send_allow || [], item.send_block || []);
|
||||
item.receive_display = sideCell(item.receive_mode, item.receive_allow || [], item.receive_block || []);
|
||||
item.assigned_display = assignedCell(item);
|
||||
item.action = '<div class="btn-group">' +
|
||||
'<a href="/edit/mail-policy/' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + esc(item.can_edit ? lang.edit : L('view')) + '</a>' +
|
||||
(item.can_edit ? '<a href="#" data-action="delete_selected" data-id="single-mail_policy" data-api-url="delete/mail-policy" data-item="' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + esc(lang.remove) + '</a>' : '') +
|
||||
'</div>';
|
||||
});
|
||||
return json;
|
||||
},
|
||||
error: function() {
|
||||
mailcow_alert_box(L('list_error'), 'danger');
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
// placeholder, so checkbox will not block child row toggle
|
||||
title: '',
|
||||
data: null,
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 1
|
||||
},
|
||||
{ title: '', data: 'chkbox', searchable: false, orderable: false, defaultContent: '', responsivePriority: 2 },
|
||||
{ title: 'ID', data: 'id', defaultContent: '', responsivePriority: 6 },
|
||||
{ title: L('name'), data: 'name_display', defaultContent: '', responsivePriority: 2 },
|
||||
{ title: L('domain'), data: 'domain', defaultContent: '', responsivePriority: 5 },
|
||||
{ title: L('send'), data: 'send_display', defaultContent: '', responsivePriority: 3 },
|
||||
{ title: L('receive'), data: 'receive_display', defaultContent: '', responsivePriority: 3 },
|
||||
{ title: L('assign'), data: 'assigned_display', defaultContent: '', responsivePriority: 4 },
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
defaultContent: '',
|
||||
responsivePriority: 6,
|
||||
render: function(d) { return d == 1 ? '<i class="bi bi-check-lg"></i>' : '<i class="bi bi-x-lg"></i>'; }
|
||||
},
|
||||
{
|
||||
title: lang.action,
|
||||
data: 'action',
|
||||
orderable: false,
|
||||
className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right',
|
||||
responsivePriority: 3,
|
||||
defaultContent: ''
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// add modal: mailboxes and groups of the chosen domain only
|
||||
function filterDomain($form) {
|
||||
var domain = $form.find('select.mail-policy-domain-select').val();
|
||||
$form.find('select.mail-policy-mailboxes, select.mail-policy-groups').each(function() {
|
||||
$(this).find('option').each(function() {
|
||||
var other = $(this).data('domain') !== domain;
|
||||
$(this).prop('hidden', other);
|
||||
if (other) $(this).prop('selected', false);
|
||||
});
|
||||
$(this).selectpicker('refresh');
|
||||
});
|
||||
}
|
||||
|
||||
function test(e) {
|
||||
e.preventDefault();
|
||||
var sender = $.trim($('#mail_policy_test_sender').val());
|
||||
var recipient = $.trim($('#mail_policy_test_recipient').val());
|
||||
if (!recipient) return;
|
||||
// an empty sender (bounces) is sent as "-"
|
||||
var url = '/api/v1/get/mail-policy/test/' + encodeURIComponent(sender || '-') + '/' + encodeURIComponent(recipient) +
|
||||
($('#mail_policy_test_login').is(':checked') ? '/login' : '');
|
||||
$.get(url, function(r) {
|
||||
var html;
|
||||
if (!r || r.allowed === undefined) {
|
||||
html = '<div class="alert alert-danger py-2">' + esc(L('test_invalid')) + '</div>';
|
||||
} else if (r.allowed == 1) {
|
||||
html = '<div class="alert alert-success py-2">' + esc(L('test_allowed')) + '</div>';
|
||||
} else {
|
||||
var reason = r.internal_alias == 1 ? L('test_denied_internal') :
|
||||
(r.policy ? L('test_denied_' + r.side).replace('%s', r.policy) : L('test_denied_other'));
|
||||
html = '<div class="alert alert-danger py-2">' + esc(reason) + '<div class="small mt-1"><code>550 5.7.1 ' + esc(r.text) + '</code></div></div>';
|
||||
}
|
||||
if (r && r.allowed !== undefined && sender && r.logged_in == 0 && $('#mail_policy_test_login').is(':checked')) {
|
||||
html += '<div class="small text-muted">' + esc(L('test_not_a_mailbox')) + '</div>';
|
||||
}
|
||||
$('#mail_policy_test_result').html(html);
|
||||
}, 'json').fail(function() {
|
||||
$('#mail_policy_test_result').html('<div class="alert alert-danger py-2">' + esc(lang_danger.generic_server_error) + '</div>');
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
$('select.mail-policy-domain-select').on('change', function() {
|
||||
filterDomain($(this).closest('form'));
|
||||
});
|
||||
$('#addMailPolicyModal').on('show.bs.modal', function() {
|
||||
filterDomain($(this).find('form'));
|
||||
});
|
||||
$('#mail_policy_test').on('submit', test);
|
||||
});
|
||||
|
||||
return {
|
||||
drawTable: drawTable
|
||||
};
|
||||
})(jQuery);
|
||||
@@ -2493,6 +2493,7 @@ jQuery(function($){
|
||||
onVisible("[id^=sync_job_table]", () => draw_sync_job_table());
|
||||
onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(role === 'admin' || acl_data.syncjobs === 1));
|
||||
onVisible("[id^=mailbox_group_table]", () => mailcowGroups.drawTable());
|
||||
onVisible("[id^=mail_policy_table]", () => mailcowMailPolicy.drawTable());
|
||||
onVisible("[id^=filter_table]", () => draw_filter_table());
|
||||
onVisible("[id^=bcc_table]", () => draw_bcc_table());
|
||||
onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table());
|
||||
|
||||
@@ -280,6 +280,9 @@ if (isset($_GET['query'])) {
|
||||
case "mailbox-group":
|
||||
process_add_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addGroup($attr));
|
||||
break;
|
||||
case "mail-policy":
|
||||
process_add_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addPolicy($attr));
|
||||
break;
|
||||
case "domain":
|
||||
switch ($object) {
|
||||
case "template":
|
||||
@@ -858,6 +861,23 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "mail-policy":
|
||||
switch ($object) {
|
||||
case "all":
|
||||
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicies($extra));
|
||||
break;
|
||||
|
||||
// test/<sender>/<recipient>[/login]: would the mail be accepted?
|
||||
case "test":
|
||||
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->test((string)$extra, (string)($query[4] ?? ''), ($query[5] ?? '') === 'login'));
|
||||
break;
|
||||
|
||||
default:
|
||||
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicy($object));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case "logs":
|
||||
switch ($object) {
|
||||
case "dovecot":
|
||||
@@ -1779,6 +1799,9 @@ if (isset($_GET['query'])) {
|
||||
case "mailbox-group":
|
||||
process_delete_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->deleteGroups($items));
|
||||
break;
|
||||
case "mail-policy":
|
||||
process_delete_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->deletePolicies($items));
|
||||
break;
|
||||
case "app-passwd":
|
||||
process_delete_return(app_passwd('delete', array('id' => $items)));
|
||||
break;
|
||||
@@ -1953,6 +1976,9 @@ if (isset($_GET['query'])) {
|
||||
case "mailbox-group":
|
||||
process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr));
|
||||
break;
|
||||
case "mail-policy":
|
||||
process_edit_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->editPolicies($items, $attr));
|
||||
break;
|
||||
case "recipient_map":
|
||||
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
|
||||
break;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"extend_sender_acl": "Eingabe externer Absenderadressen erlauben",
|
||||
"filters": "Filter",
|
||||
"login_as": "Einloggen als Mailbox-Benutzer",
|
||||
"mail_policies": "Mail-Richtlinien",
|
||||
"mailbox_groups": "Gruppen",
|
||||
"mailbox_relayhost": "Relayhost für eine Mailbox setzen",
|
||||
"prohibited": "Untersagt durch Richtlinie",
|
||||
@@ -66,7 +67,7 @@
|
||||
"hostname": "Host",
|
||||
"inactive": "Inaktiv",
|
||||
"internal": "Intern",
|
||||
"internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.",
|
||||
"internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).",
|
||||
"sender_allowed": "Als dieser Alias senden erlauben",
|
||||
"kind": "Art",
|
||||
"mailbox_quota_def": "Standard-Quota einer Mailbox",
|
||||
@@ -582,6 +583,14 @@
|
||||
"is_spam_alias": "%s lautet bereits eine temporäre Alias-Adresse (Spam-Alias-Adresse)",
|
||||
"last_key": "Letzter Key kann nicht gelöscht werden, bitte stattdessen die 2FA deaktivieren.",
|
||||
"login_failed": "Anmeldung fehlgeschlagen",
|
||||
"mail_policy_description_invalid": "Ungültige Beschreibung",
|
||||
"mail_policy_entries_exceeded": "Eine Liste darf höchstens %s Einträge enthalten",
|
||||
"mail_policy_entry_invalid": "Ungültige Adresse, Domain oder Gruppe in der Liste: %s",
|
||||
"mail_policy_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Richtlinie",
|
||||
"mail_policy_mode_invalid": "Ungültiger Modus",
|
||||
"mail_policy_name_invalid": "Ungültiger Name (1 bis 100 Zeichen)",
|
||||
"mail_policy_reject_text_invalid": "Ungültiger Ablehnungstext: druckbare ASCII-Zeichen, höchstens 200",
|
||||
"mail_policy_test_invalid": "Ungültiger Absender oder Empfänger",
|
||||
"mailbox_defquota_exceeds_mailbox_maxquota": "Standard-Quota überschreitet das Limit der maximal erlaubten Größe einer Mailbox",
|
||||
"mailbox_invalid": "Mailboxname ist ungültig",
|
||||
"mailbox_quota_exceeded": "Speicherplatz überschreitet das Limit (max. %d MiB)",
|
||||
@@ -829,7 +838,7 @@
|
||||
"iam_unlink": "Verknüpfung lösen",
|
||||
"inactive": "Inaktiv",
|
||||
"internal": "Intern",
|
||||
"internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.",
|
||||
"internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).",
|
||||
"sender_allowed": "Als dieser Alias senden erlauben",
|
||||
"sender_allowed_info": "Wenn deaktiviert, kann dieser Alias nur E-Mails empfangen. Verwenden Sie Sender-ACL, um bestimmten Postfächern die Berechtigung zum Senden zu erteilen.",
|
||||
"kind": "Art",
|
||||
@@ -969,7 +978,7 @@
|
||||
"imap_grants": "Postfachzugriff",
|
||||
"imap_grants_hint": "Mitglieder öffnen diese Mailboxen in ihrem Mailprogramm (Ordner Shared/<mailbox>). Lesen: lesen und als gelesen markieren. Schreiben: zusätzlich ablegen, verschieben und löschen. Vollzugriff: zusätzlich Ordner löschen. Team-Postfächer ohne eigenen Login als geteiltes Postfach markieren.",
|
||||
"info": "Gruppen bündeln Postfächer. Eine Gruppe kann eine eigene Adresse haben: ein Alias, der automatisch an alle Mitglieder zustellt. Mitglieder können als andere Adressen senden, auf freigegebene Postfächer, Kalender und Adressbücher zugreifen und ein Postfach-Template erhalten. Vom Identity Provider synchronisierte Mitglieder sind mit IdP markiert.",
|
||||
"internal": "Nur Mails von Absendern der Domain annehmen",
|
||||
"internal": "Nur E-Mails von angemeldeten Absendern der Domain annehmen",
|
||||
"list_error": "Gruppen konnten nicht geladen werden",
|
||||
"managed_alias": "Wird von Gruppe %s verwaltet",
|
||||
"members": "Mitglieder",
|
||||
@@ -1036,6 +1045,59 @@
|
||||
"username": "Benutzername",
|
||||
"email": "E-Mail-Adresse"
|
||||
},
|
||||
"mail_policy": {
|
||||
"add": "Mail-Richtlinie hinzufügen",
|
||||
"allow_receive": "Erlaubte Absender",
|
||||
"allow_send": "Erlaubte Empfänger",
|
||||
"assign": "Zugewiesen an",
|
||||
"assign_info": "Die Richtlinie gilt für diese Mailboxen und für die Mitglieder dieser Gruppen, bei einer Gruppe auch für E-Mails an die Gruppenadresse. E-Mails an einen Alias oder eine andere Gruppe erreichen die Mitglieder ohne deren Richtlinien.",
|
||||
"assigned_none": "Nicht zugewiesen",
|
||||
"block_receive": "Gesperrte Absender",
|
||||
"block_send": "Gesperrte Empfänger",
|
||||
"description": "Beschreibung",
|
||||
"domain": "Domain",
|
||||
"entries_info": "Adressen oder Domains, eine pro Zeile, und Gruppen. Adressen und Domains dieser mailcow gelten nur für angemeldete Absender.",
|
||||
"groups": "Gruppen",
|
||||
"info": "Mail-Richtlinien legen fest, an wen Mailboxen und Gruppen senden und von wem sie empfangen dürfen, z. B. nur intern oder nur mit bestimmten Domains. Postfix prüft sie für jeden Empfänger und lehnt nicht erlaubte Empfänger mit einem 550-Fehler ab. Eingehende E-Mails erreichen die übrigen Empfänger trotzdem, SOGo sendet eine E-Mail mit einem abgelehnten Empfänger dagegen gar nicht.",
|
||||
"internal_info": "Intern: Empfänger in der Domain der Richtlinie und ihren Alias-Domains; Absender, die mit einer Mailbox dieser Domain angemeldet sind.",
|
||||
"list_error": "Die Mail-Richtlinien konnten nicht geladen werden",
|
||||
"mail_policies": "Mail-Richtlinien",
|
||||
"mailboxes": "Mailboxen",
|
||||
"mode": "Erlaubt",
|
||||
"mode_receive_all": "Alle Absender",
|
||||
"mode_receive_internal": "Interne Absender und die erlaubten Absender",
|
||||
"mode_receive_list": "Nur die erlaubten Absender",
|
||||
"mode_send_all": "Alle Empfänger",
|
||||
"mode_send_internal": "Interne Empfänger und die erlaubten Empfänger",
|
||||
"mode_send_list": "Nur die erlaubten Empfänger",
|
||||
"name": "Name",
|
||||
"policy": "Mail-Richtlinie",
|
||||
"receive": "Empfangen",
|
||||
"receive_info": "Für E-Mails an eine zugewiesene Mailbox oder an die Adresse einer zugewiesenen Gruppe.",
|
||||
"reject_text": "Ablehnungstext",
|
||||
"reject_text_info": "Text der SMTP-Ablehnung (550 5.7.1 …), nur ASCII. Leer: ein Standardtext.",
|
||||
"send": "Senden",
|
||||
"send_info": "Für E-Mails, die eine zugewiesene Mailbox angemeldet sendet oder die unter ihrer Adresse gesendet werden, auch Weiterleitungen per Filter und Abwesenheitsnotizen.",
|
||||
"summary_all": "Alle",
|
||||
"summary_allowed": "erlaubt",
|
||||
"summary_blocked": "gesperrt",
|
||||
"summary_internal": "Intern",
|
||||
"summary_list": "Nur erlaubte",
|
||||
"test": "Regel testen",
|
||||
"test_allowed": "Angenommen: Keine Richtlinie lehnt diese E-Mail ab.",
|
||||
"test_denied_internal": "Abgelehnt: Die Adresse nimmt nur E-Mails von angemeldeten Absendern ihrer Domain an (interner Alias oder interne Gruppe).",
|
||||
"test_denied_other": "Abgelehnt durch eine Richtlinie einer anderen Domain.",
|
||||
"test_denied_receive": "Abgelehnt durch den Empfangen-Teil der Richtlinie „%s“.",
|
||||
"test_denied_send": "Abgelehnt durch den Senden-Teil der Richtlinie „%s“.",
|
||||
"test_info": "Prüft, ob Postfix die E-Mail für diesen Empfänger annehmen würde. Für Bounces den Absender leer lassen.",
|
||||
"test_invalid": "Geben Sie einen gültigen Empfänger (und Absender) Ihrer Domains ein.",
|
||||
"test_logged_in": "Absender ist angemeldet",
|
||||
"test_not_a_mailbox": "Der Absender ist keine Mailbox dieser mailcow und wurde als nicht angemeldet geprüft.",
|
||||
"test_recipient": "Empfänger",
|
||||
"test_run": "Testen",
|
||||
"test_sender": "Absender",
|
||||
"view": "Ansehen"
|
||||
},
|
||||
"mailbox": {
|
||||
"action": "Aktion",
|
||||
"activate": "Aktivieren",
|
||||
@@ -1134,6 +1196,9 @@
|
||||
"no_record": "Kein Eintrag für Objekt %s",
|
||||
"no_record_single": "Kein Eintrag",
|
||||
"owner": "Besitzer",
|
||||
"policies": "Richtlinien",
|
||||
"policies_mail": "Mail",
|
||||
"policies_tls": "TLS",
|
||||
"private_comment": "Privater Kommentar",
|
||||
"public_comment": "Öffentlicher Kommentar",
|
||||
"q_add_header": "bei Mail in Junk-Ordner",
|
||||
@@ -1348,6 +1413,9 @@
|
||||
"learned_ham": "ID %s wurde erfolgreich als Ham gelernt",
|
||||
"license_modified": "Änderungen an Lizenz wurden gespeichert",
|
||||
"logged_in_as": "Eingeloggt als %s",
|
||||
"mail_policy_added": "Mail-Richtlinie %s hinzugefügt",
|
||||
"mail_policy_modified": "Mail-Richtlinie %s gespeichert",
|
||||
"mail_policy_removed": "Mail-Richtlinie %s entfernt",
|
||||
"mailbox_added": "Mailbox %s wurde angelegt",
|
||||
"mailbox_modified": "Änderungen an Mailbox %s wurden gespeichert",
|
||||
"mailbox_removed": "Mailbox %s wurde entfernt",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"extend_sender_acl": "Allow to extend sender ACL by external addresses",
|
||||
"filters": "Filters",
|
||||
"login_as": "Login as mailbox user",
|
||||
"mail_policies": "Mail policies",
|
||||
"mailbox_groups": "Groups",
|
||||
"mailbox_relayhost": "Change relayhost for a mailbox",
|
||||
"prohibited": "Prohibited by ACL",
|
||||
@@ -66,7 +67,7 @@
|
||||
"hostname": "Host",
|
||||
"inactive": "Inactive",
|
||||
"internal": "Internal",
|
||||
"internal_info": "Internal aliases are only accessible from the own domain or alias domains.",
|
||||
"internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).",
|
||||
"sender_allowed": "Allow to send as this alias",
|
||||
"kind": "Kind",
|
||||
"mailbox_quota_def": "Default mailbox quota",
|
||||
@@ -518,6 +519,14 @@
|
||||
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
|
||||
"iam_sync_mode_invalid": "Invalid sync mode",
|
||||
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
|
||||
"mail_policy_description_invalid": "Invalid description",
|
||||
"mail_policy_entries_exceeded": "A list may contain at most %s entries",
|
||||
"mail_policy_entry_invalid": "Invalid address, domain or group in the list: %s",
|
||||
"mail_policy_member_invalid": "Mailbox or group %s does not belong to the domain of the policy",
|
||||
"mail_policy_mode_invalid": "Invalid mode",
|
||||
"mail_policy_name_invalid": "Invalid name (1 to 100 characters)",
|
||||
"mail_policy_reject_text_invalid": "Invalid rejection text: printable ASCII, at most 200 characters",
|
||||
"mail_policy_test_invalid": "Invalid sender or recipient",
|
||||
"tfa_login_locked": "Too many failed two-factor logins, please try again later",
|
||||
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
|
||||
"alias_domain_invalid": "Alias domain %s is invalid",
|
||||
@@ -829,7 +838,7 @@
|
||||
"iam_unlink": "Unlink",
|
||||
"inactive": "Inactive",
|
||||
"internal": "Internal",
|
||||
"internal_info": "Internal aliases are only accessible from the own domain or alias domains.",
|
||||
"internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).",
|
||||
"sender_allowed": "Allow to send as this alias",
|
||||
"sender_allowed_info": "If disabled, this alias can only receive mail. Use sender ACL to override and grant specific mailboxes permission to send.",
|
||||
"kind": "Kind",
|
||||
@@ -969,7 +978,7 @@
|
||||
"imap_grants": "Mailbox access",
|
||||
"imap_grants_hint": "Members open these mailboxes in their mail client (folder Shared/<mailbox>). Read: read and mark as read. Write: also file, move and delete mails. Full access: also delete folders. For team mailboxes without a login of their own, mark the mailbox as shared mailbox.",
|
||||
"info": "Groups bundle mailboxes. A group can have its own address: an alias that delivers to all members automatically. Members can send as other addresses, access shared mailboxes, calendars and address books and get a mailbox template. Members synced from the identity provider are marked IdP.",
|
||||
"internal": "Only accept mail from senders of the domain",
|
||||
"internal": "Only accept mail from logged-in senders of the domain",
|
||||
"list_error": "Groups could not be loaded",
|
||||
"managed_alias": "Managed by group %s",
|
||||
"members": "Members",
|
||||
@@ -1036,6 +1045,59 @@
|
||||
"username": "Username",
|
||||
"email": "Email address"
|
||||
},
|
||||
"mail_policy": {
|
||||
"add": "Add mail policy",
|
||||
"allow_receive": "Allowed senders",
|
||||
"allow_send": "Allowed recipients",
|
||||
"assign": "Assigned to",
|
||||
"assign_info": "The policy applies to these mailboxes and to the members of these groups; for a group also to mail sent to the group address. Mail sent to an alias or another group reaches the members without their policies.",
|
||||
"assigned_none": "Not assigned",
|
||||
"block_receive": "Blocked senders",
|
||||
"block_send": "Blocked recipients",
|
||||
"description": "Description",
|
||||
"domain": "Domain",
|
||||
"entries_info": "Addresses or domains, one per line, and groups. Addresses and domains of this mailcow count for logged-in senders only.",
|
||||
"groups": "Groups",
|
||||
"info": "Mail policies define whom mailboxes and groups may send to and receive from, e.g. only internally or only with certain domains. Postfix checks them for every recipient and refuses recipients that are not allowed with a 550 error. Incoming mail still reaches the other recipients, whereas SOGo does not send a mail with a refused recipient at all.",
|
||||
"internal_info": "Internal: recipients in the domain of the policy and its alias domains; senders logged in with a mailbox of this domain.",
|
||||
"list_error": "The mail policies could not be loaded",
|
||||
"mail_policies": "Mail policies",
|
||||
"mailboxes": "Mailboxes",
|
||||
"mode": "Allowed",
|
||||
"mode_receive_all": "All senders",
|
||||
"mode_receive_internal": "Internal senders and the allowed senders",
|
||||
"mode_receive_list": "Only the allowed senders",
|
||||
"mode_send_all": "All recipients",
|
||||
"mode_send_internal": "Internal recipients and the allowed recipients",
|
||||
"mode_send_list": "Only the allowed recipients",
|
||||
"name": "Name",
|
||||
"policy": "Mail policy",
|
||||
"receive": "Receiving",
|
||||
"receive_info": "For mail to an assigned mailbox or to the address of an assigned group.",
|
||||
"reject_text": "Rejection text",
|
||||
"reject_text_info": "Text of the SMTP rejection (550 5.7.1 ...), ASCII only. Empty: a default text.",
|
||||
"send": "Sending",
|
||||
"send_info": "For mail an assigned mailbox sends while logged in, or that is sent with its address, including redirects by filters and vacation replies.",
|
||||
"summary_all": "All",
|
||||
"summary_allowed": "allowed",
|
||||
"summary_blocked": "blocked",
|
||||
"summary_internal": "Internal",
|
||||
"summary_list": "Allowed only",
|
||||
"test": "Test a rule",
|
||||
"test_allowed": "Accepted: no policy refuses this mail.",
|
||||
"test_denied_internal": "Refused: the address only accepts mail from logged-in senders of its domain (internal alias or group).",
|
||||
"test_denied_other": "Refused by a policy of another domain.",
|
||||
"test_denied_receive": "Refused by the receiving part of the policy \"%s\".",
|
||||
"test_denied_send": "Refused by the sending part of the policy \"%s\".",
|
||||
"test_info": "Checks whether Postfix would accept the mail for this recipient. Leave the sender empty for bounces.",
|
||||
"test_invalid": "Enter a valid recipient (and sender) of your domains.",
|
||||
"test_logged_in": "Sender is logged in",
|
||||
"test_not_a_mailbox": "The sender is no mailbox of this mailcow and was tested as not logged in.",
|
||||
"test_recipient": "Recipient",
|
||||
"test_run": "Test",
|
||||
"test_sender": "Sender",
|
||||
"view": "View"
|
||||
},
|
||||
"mailbox": {
|
||||
"action": "Action",
|
||||
"activate": "Activate",
|
||||
@@ -1141,6 +1203,9 @@
|
||||
"no_record_single": "No record",
|
||||
"open_logs": "Open logs",
|
||||
"owner": "Owner",
|
||||
"policies": "Policies",
|
||||
"policies_mail": "Mail",
|
||||
"policies_tls": "TLS",
|
||||
"private_comment": "Private comment",
|
||||
"public_comment": "Public comment",
|
||||
"q_add_header": "when moved to Junk folder",
|
||||
@@ -1355,6 +1420,9 @@
|
||||
"learned_ham": "Successfully learned ID %s as ham",
|
||||
"license_modified": "Changes to license have been saved",
|
||||
"logged_in_as": "Logged in as %s",
|
||||
"mail_policy_added": "Mail policy %s added",
|
||||
"mail_policy_modified": "Mail policy %s saved",
|
||||
"mail_policy_removed": "Mail policy %s removed",
|
||||
"mailbox_added": "Mailbox %s has been added",
|
||||
"mailbox_modified": "Changes to mailbox %s have been saved",
|
||||
"mailbox_removed": "Mailbox %s has been removed",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
{# Fields of a mail policy (add modal, edit page): policy, id (prefix of the element ids), groups (choices: id, name,
|
||||
domain, address), mailboxes (choices), ro, label_class. Groups of a list are sent apart as <list>_groups. #}
|
||||
{% set entries = {} %}
|
||||
{% set listed = {} %}
|
||||
{% for key in ['send_allow', 'send_block', 'receive_allow', 'receive_block'] %}
|
||||
{% set plain = [] %}
|
||||
{% set group_ids = [] %}
|
||||
{% for entry in attribute(policy, key)|default([]) %}
|
||||
{% if entry starts with 'group:' %}
|
||||
{% set group_ids = group_ids|merge([entry|slice(6)]) %}
|
||||
{% else %}
|
||||
{% set plain = plain|merge([entry]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set entries = entries|merge({(key): plain|join("\n")}) %}
|
||||
{% set listed = listed|merge({(key): group_ids}) %}
|
||||
{% endfor %}
|
||||
{% for side in ['send', 'receive'] %}
|
||||
<legend class="fs-5 mt-4"><i class="bi {{ side == 'send' ? 'bi-send' : 'bi-inbox' }}"></i> {{ attribute(lang.mail_policy, side) }}</legend><hr>
|
||||
<p class="text-muted small">{{ attribute(lang.mail_policy, side ~ '_info') }}</p>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_{{ side }}_mode">{{ lang.mail_policy.mode }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" name="{{ side }}_mode" id="{{ id }}_{{ side }}_mode"{% if ro %} disabled{% endif %}>
|
||||
{% for mode in ['all', 'internal', 'list'] %}
|
||||
<option value="{{ mode }}"{% if attribute(policy, side ~ '_mode')|default('all') == mode %} selected{% endif %}>{{ attribute(lang.mail_policy, 'mode_' ~ side ~ '_' ~ mode) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<small class="text-muted d-block">{{ lang.mail_policy.internal_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% for list in ['allow', 'block'] %}
|
||||
{% set key = side ~ '_' ~ list %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_{{ key }}">{{ attribute(lang.mail_policy, list ~ '_' ~ side) }}</label>
|
||||
<div class="col-sm-10">
|
||||
<textarea class="form-control" rows="3" name="{{ key }}" id="{{ id }}_{{ key }}" placeholder="partner.example, user@example.org"{% if ro %} disabled{% endif %}>{{ attribute(entries, key) }}</textarea>
|
||||
<small class="text-muted d-block mb-1">{{ lang.mail_policy.entries_info }}</small>
|
||||
{% if not ro %}<input type="hidden" name="{{ key }}_groups" value="">{% endif %}
|
||||
<select data-live-search="true" data-width="100%" name="{{ key }}_groups" class="form-control mail-policy-groups" multiple title="{{ lang.mail_policy.groups }}"{% if ro %} disabled{% endif %}>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id ~ '' in attribute(listed, key) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_reject_text">{{ lang.mail_policy.reject_text }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="reject_text" id="{{ id }}_reject_text" maxlength="200" value="{{ policy.reject_text|default('') }}"{% if ro %} disabled{% endif %}>
|
||||
<small class="text-muted d-block">{{ lang.mail_policy.reject_text_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<legend class="fs-5 mt-4"><i class="bi bi-people"></i> {{ lang.mail_policy.assign }}</legend><hr>
|
||||
<p class="text-muted small">{{ lang.mail_policy.assign_info }}</p>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_mailboxes">{{ lang.mail_policy.mailboxes }}</label>
|
||||
<div class="col-sm-10">
|
||||
{% if not ro %}<input type="hidden" name="mailboxes" value="">{% endif %}
|
||||
<select data-live-search="true" data-width="100%" name="mailboxes" id="{{ id }}_mailboxes" class="form-control mail-policy-mailboxes" multiple title="{{ lang.mail_policy.mailboxes }}"{% if ro %} disabled{% endif %}>
|
||||
{% for mailbox in mailboxes %}
|
||||
<option value="{{ mailbox }}" data-domain="{{ mailbox|split('@')|last }}"{% if mailbox in policy.mailboxes|default([]) %} selected{% endif %}>{{ mailbox }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_groups">{{ lang.mail_policy.groups }}</label>
|
||||
<div class="col-sm-10">
|
||||
{% if not ro %}<input type="hidden" name="groups" value="">{% endif %}
|
||||
<select data-live-search="true" data-width="100%" name="groups" id="{{ id }}_groups" class="form-control mail-policy-groups" multiple title="{{ lang.mail_policy.groups }}"{% if ro %} disabled{% endif %}>
|
||||
{% for group in groups %}
|
||||
<option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id in policy.groups|default([]) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends 'edit.twig' %}
|
||||
|
||||
{% block inner_content %}
|
||||
{% if result %}
|
||||
{% set ro = not result.can_edit %}
|
||||
<h4>{{ lang.mail_policy.policy }}: {{ result.name }}</h4>
|
||||
<form data-id="edit_mail_policy" class="form-horizontal" role="form" method="post" autocapitalize="none" autocorrect="off">
|
||||
<input type="hidden" value="0" name="active">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="mail_policy_domain">{{ lang.mail_policy.domain }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="mail_policy_domain" value="{{ result.domain }}" disabled>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="mail_policy_name">{{ lang.mail_policy.name }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="name" id="mail_policy_name" value="{{ result.name }}" maxlength="100" required{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="mail_policy_description">{{ lang.mail_policy.description }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="description" id="mail_policy_description" value="{{ result.description }}" maxlength="255"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'edit/mail-policy-fields.twig' with {'policy': result, 'id': 'mail_policy', 'groups': mail_policy_groups, 'mailboxes': mailboxes, 'ro': ro, 'label_class': ''} %}
|
||||
<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 ro %} disabled{% endif %}> {{ lang.mailbox.active }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not ro %}
|
||||
<div class="row">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_mail_policy" data-item="{{ result.id }}" data-api-url='edit/mail-policy' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
{{ parent() }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -36,10 +36,20 @@
|
||||
{% if acl.mailbox_groups == 1 %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-groups" role="tab" data-bs-toggle="tab" data-bs-target="#tab-groups">{{ lang.groups.groups }}</button></li>
|
||||
{% endif %}
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.policies }}</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="tab-tls-policy" role="tab" data-bs-toggle="tab" data-bs-target="#tab-tls-policy">{{ lang.mailbox.policies_tls }}</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="tab-mail-policies" role="tab" data-bs-toggle="tab" data-bs-target="#tab-mail-policies">{{ lang.mailbox.policies_mail }}</button></li>
|
||||
</ul>
|
||||
</li>
|
||||
{% elseif acl.mail_policies == 1 %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-mail-policies" role="tab" data-bs-toggle="tab" data-bs-target="#tab-mail-policies">{{ lang.mail_policy.mail_policies }}</button></li>
|
||||
{% endif %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#tab-syncjobs">{{ lang.syncjobs.sync_jobs }}</button></li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-filters" role="tab" data-bs-toggle="tab" data-bs-target="#tab-filters">{{ lang.mailbox.filters }}</button></li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-bcc" role="tab" data-bs-toggle="tab" data-bs-target="#tab-bcc">{{ lang.mailbox.address_rewriting }}</button></li>
|
||||
<li class="nav-item" role="presentation"{% if mailcow_cc_role != 'admin' %} class="d-none"{% endif %}><button class="nav-link" aria-selected="false" aria-controls="tab-tls-policy" role="tab" data-bs-toggle="tab" data-bs-target="#tab-tls-policy">{{ lang.mailbox.tls_policy_maps }}</button></li>
|
||||
</ul>
|
||||
|
||||
<div class="row">
|
||||
@@ -59,6 +69,9 @@
|
||||
{% if acl.mailbox_groups == 1 %}
|
||||
{% include 'mailbox/tab-groups.twig' %}
|
||||
{% endif %}
|
||||
{% if acl.mail_policies == 1 %}
|
||||
{% include 'mailbox/tab-mail-policies.twig' %}
|
||||
{% endif %}
|
||||
{% include 'mailbox/tab-syncjobs.twig' %}
|
||||
{% include 'mailbox/tab-filters.twig' %}
|
||||
{% include 'mailbox/tab-bcc.twig' %}
|
||||
@@ -75,6 +88,7 @@
|
||||
var lang = {{ lang_mailbox|raw }};
|
||||
lang.syncjobs = {{ lang_syncjobs|raw }};
|
||||
lang.groups = {{ lang_groups|raw }};
|
||||
lang.mail_policy = {{ lang_mail_policy|raw }};
|
||||
var lang_rl = {{ lang_rl|raw }};
|
||||
var lang_edit = {{ lang_edit|raw }};
|
||||
var lang_datatables = {{ lang_datatables|raw }};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<div class="tab-pane fade" id="tab-mail-policies" role="tabpanel" aria-labelledby="tab-mail-policies">
|
||||
<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-mail-policies" data-bs-toggle="collapse" aria-controls="collapse-tab-mail-policies">
|
||||
{{ lang.mail_policy.mail_policies }} <span class="badge bg-info table-lines"></span>
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.mail_policy.mail_policies }} <span class="badge bg-info table-lines"></span></span>
|
||||
<div class="btn-group ms-auto d-flex">
|
||||
<button class="btn btn-xs btn-secondary refresh_table" data-table="mail_policy_table">{{ lang.admin.refresh }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collapse-tab-mail-policies" class="card-body collapse" data-bs-parent="#mail-content">
|
||||
<p class="text-muted">{{ lang.mail_policy.info }}</p>
|
||||
<table id="mail_policy_table" class="table table-striped dt-responsive w-100"></table>
|
||||
<div class="mass-actions-mailbox mt-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="mail_policy" 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="mail_policy" data-api-url='edit/mail-policy' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="mail_policy" data-api-url='edit/mail-policy' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
<li><a class="dropdown-item" data-action="delete_selected" data-id="mail_policy" data-api-url='delete/mail-policy' href="#">{{ lang.mailbox.remove }}</a></li>
|
||||
</ul>
|
||||
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addMailPolicyModal"><i class="bi bi-plus-lg"></i> {{ lang.mail_policy.add }}</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Test a rule: would the mail be accepted? Filled by js/site/mail-policy.js #}
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">{{ lang.mail_policy.test }}</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">{{ lang.mail_policy.test_info }}</p>
|
||||
<form class="row g-2 align-items-center" id="mail_policy_test" autocomplete="off">
|
||||
<div class="col-12 col-md-4">
|
||||
<input type="email" class="form-control" id="mail_policy_test_sender" placeholder="{{ lang.mail_policy.test_sender }}" aria-label="{{ lang.mail_policy.test_sender }}">
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<input type="email" class="form-control" id="mail_policy_test_recipient" placeholder="{{ lang.mail_policy.test_recipient }}" aria-label="{{ lang.mail_policy.test_recipient }}" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 d-flex align-items-center">
|
||||
<div class="form-check me-3">
|
||||
<input class="form-check-input" type="checkbox" id="mail_policy_test_login" checked>
|
||||
<label class="form-check-label" for="mail_policy_test_login">{{ lang.mail_policy.test_logged_in }}</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-sm btn-secondary ms-auto"><i class="bi bi-play"></i> {{ lang.mail_policy.test_run }}</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="mail_policy_test_result" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,50 @@
|
||||
<div class="modal fade" id="addMailPolicyModal" 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.mail_policy.add }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal" role="form" data-id="add_mail_policy" autocapitalize="none" autocorrect="off">
|
||||
<input type="hidden" value="0" name="active">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="add_mail_policy_domain">{{ lang.mail_policy.domain }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select data-live-search="true" name="domain" id="add_mail_policy_domain" class="form-control mail-policy-domain-select" required>
|
||||
{% for domain in domains %}
|
||||
<option>{{ domain }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="add_mail_policy_name">{{ lang.mail_policy.name }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="name" id="add_mail_policy_name" maxlength="100" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="add_mail_policy_description">{{ lang.mail_policy.description }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="description" id="add_mail_policy_description" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
{% include 'edit/mail-policy-fields.twig' with {'policy': {}, 'id': 'add_mail_policy', 'groups': mail_policy_groups, 'mailboxes': mailboxes, 'ro': false, 'label_class': ' text-sm-end'} %}
|
||||
<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" value="1" name="active" checked> {{ lang.mailbox.active }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<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_mail_policy" data-api-url='add/mail-policy' data-api-attr='{}' href="#">{{ lang.admin.add }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1362,3 +1362,6 @@
|
||||
{% if acl.mailbox_groups == 1 %}
|
||||
{% include "modals/mailbox_group.twig" %}
|
||||
{% endif %}
|
||||
{% if acl.mail_policies == 1 %}
|
||||
{% include "modals/mail_policy.twig" %}
|
||||
{% endif %}
|
||||
Reference in new issue
Block a user