mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-08 02:07:07 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0175fce9b5 | ||
|
|
1c24de19c3 | ||
|
|
fb85a9eb5c | ||
|
|
fc56d53029 | ||
|
|
2ac2edf0ad |
No files matched your search
@@ -26,7 +26,6 @@ RUN groupadd -g 102 postfix \
|
||||
postfix \
|
||||
postfix-mysql \
|
||||
postfix-pcre \
|
||||
python3 \
|
||||
redis-tools \
|
||||
sasl2-bin \
|
||||
sudo \
|
||||
@@ -47,7 +46,6 @@ COPY postfix.sh /opt/postfix.sh
|
||||
COPY rspamd-pipe-ham /usr/local/bin/rspamd-pipe-ham
|
||||
COPY rspamd-pipe-spam /usr/local/bin/rspamd-pipe-spam
|
||||
COPY whitelist_forwardinghosts.sh /usr/local/bin/whitelist_forwardinghosts.sh
|
||||
COPY mailcow-policy.py /usr/local/bin/mailcow-policy.py
|
||||
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||
|
||||
@@ -55,7 +53,6 @@ RUN chmod +x /opt/postfix.sh \
|
||||
/usr/local/bin/rspamd-pipe-ham \
|
||||
/usr/local/bin/rspamd-pipe-spam \
|
||||
/usr/local/bin/whitelist_forwardinghosts.sh \
|
||||
/usr/local/bin/mailcow-policy.py \
|
||||
/usr/local/sbin/stop-supervisor.sh
|
||||
RUN rm -rf /tmp/* /var/tmp/*
|
||||
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
# Policy service of the mail policies (mailcow-policy in master.cf, spawned by Postfix): reads the policy requests
|
||||
# of smtpd and asks the mailcow UI for the action (nginx:8081/mailpolicy.php). Without an answer the recipient is
|
||||
# deferred; smtpd only asks while policies or internal aliases exist (mysql_mail_policy_maps.cf).
|
||||
|
||||
import http.client
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
from urllib.parse import urlencode
|
||||
|
||||
# one line: DUNNO or a rejection with code and text
|
||||
valid_action = re.compile(rb'DUNNO|[45]\d\d [45]\.\d{1,3}\.\d{1,3} [\x20-\x7e]{1,200}')
|
||||
unavailable = b'DEFER_IF_PERMIT Mail policy check temporarily unavailable'
|
||||
# address of nginx, resolved once per process (a lookup costs more than the check)
|
||||
nginx = None
|
||||
|
||||
def check(request):
|
||||
global nginx
|
||||
body = urlencode(sorted(request.items())).encode()
|
||||
try:
|
||||
if nginx is None:
|
||||
nginx = socket.gethostbyname('nginx')
|
||||
# http.client sets TCP_NODELAY, so the body is not held back until the headers are acknowledged
|
||||
# below the timeout of Postfix (main.cf), so an answer always arrives
|
||||
conn = http.client.HTTPConnection(nginx, 8081, timeout=8)
|
||||
try:
|
||||
conn.request('POST', '/mailpolicy.php', body, {'Host': 'nginx', 'Content-Type': 'application/x-www-form-urlencoded'})
|
||||
response = conn.getresponse()
|
||||
action = response.read()
|
||||
finally:
|
||||
conn.close()
|
||||
except (OSError, http.client.HTTPException):
|
||||
# nginx may have a new address after a restart
|
||||
nginx = None
|
||||
return unavailable
|
||||
return action if response.status == 200 and valid_action.fullmatch(action) else unavailable
|
||||
|
||||
def main():
|
||||
request = {}
|
||||
# attributes as bytes: addresses may contain any 8-bit characters
|
||||
for line in sys.stdin.buffer:
|
||||
line = line.rstrip(b'\r\n')
|
||||
if line:
|
||||
name, separator, value = line.partition(b'=')
|
||||
if separator:
|
||||
request[name] = value
|
||||
continue
|
||||
sys.stdout.buffer.write(b'action=' + check(request) + b'\n\n')
|
||||
sys.stdout.buffer.flush()
|
||||
request = {}
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -178,6 +178,45 @@ query = SELECT CONCAT('smtp_via_transport_maps:', nexthop) AS transport FROM tra
|
||||
AND destination = '%s';
|
||||
EOF
|
||||
|
||||
# Archive copies (journal) to the archive set in the admin UI: on port 591 the sender archive-bounce@localhost
|
||||
# sends the whole mail through archive_smtp (master.cf), whatever the recipient domain
|
||||
cat <<EOF > /opt/postfix/conf/sql/mysql_archive_sender_filter_maps.cf
|
||||
# Autogenerated by mailcow
|
||||
user = ${DBUSER}
|
||||
password = ${DBPASS}
|
||||
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||
dbname = ${DBNAME}
|
||||
query = SELECT CONCAT('FILTER archive_smtp:', nexthop) AS action FROM archive_transport
|
||||
WHERE active = '1'
|
||||
AND '%s' = 'archive-bounce@localhost'
|
||||
LIMIT 1;
|
||||
EOF
|
||||
|
||||
cat <<EOF > /opt/postfix/conf/sql/mysql_archive_sasl_passwd_maps.cf
|
||||
# Autogenerated by mailcow
|
||||
user = ${DBUSER}
|
||||
password = ${DBPASS}
|
||||
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||
dbname = ${DBNAME}
|
||||
query = SELECT CONCAT_WS(':', username, password) AS auth_data FROM archive_transport
|
||||
WHERE nexthop = '%s'
|
||||
AND active = '1'
|
||||
AND username != ''
|
||||
LIMIT 1;
|
||||
EOF
|
||||
|
||||
cat <<EOF > /opt/postfix/conf/sql/mysql_archive_tls_policy_maps.cf
|
||||
# Autogenerated by mailcow
|
||||
user = ${DBUSER}
|
||||
password = ${DBPASS}
|
||||
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||
dbname = ${DBNAME}
|
||||
query = SELECT tls_policy FROM archive_transport
|
||||
WHERE nexthop = '%s'
|
||||
AND active = '1'
|
||||
LIMIT 1;
|
||||
EOF
|
||||
|
||||
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_resource_maps.cf
|
||||
# Autogenerated by mailcow
|
||||
user = ${DBUSER}
|
||||
@@ -239,19 +278,6 @@ query = SELECT CONCAT_WS(':', username, password) AS auth_data FROM transports
|
||||
LIMIT 1;
|
||||
EOF
|
||||
|
||||
# Mail policies: while policies or internal aliases exist, smtpd asks the policy service (mailcow_mail_policy in main.cf)
|
||||
cat <<EOF > /opt/postfix/conf/sql/mysql_mail_policy_maps.cf
|
||||
# Autogenerated by mailcow
|
||||
user = ${DBUSER}
|
||||
password = ${DBPASS}
|
||||
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||
dbname = ${DBNAME}
|
||||
query = SELECT 'mailcow_mail_policy' FROM DUAL WHERE '%s' != ''
|
||||
AND (EXISTS (SELECT 1 FROM mail_policy_member JOIN mail_policy ON mail_policy.id = mail_policy_member.policy_id
|
||||
WHERE mail_policy.active = '1')
|
||||
OR EXISTS (SELECT 1 FROM alias WHERE internal = '1' AND active IN ('1', '2')));
|
||||
EOF
|
||||
|
||||
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_alias_domain_maps.cf
|
||||
# Autogenerated by mailcow
|
||||
user = ${DBUSER}
|
||||
|
||||
@@ -199,8 +199,6 @@ plugin {
|
||||
sieve_plugins = sieve_imapsieve sieve_extprograms
|
||||
sieve_vacation_send_from_recipient = yes
|
||||
sieve_redirect_envelope_from = recipient
|
||||
# Notifications are sent with the address of the mailbox (not :from or postmaster), so mail policies apply
|
||||
sieve_notify_mailto_envelope_from = recipient
|
||||
# From elsewhere to Spam folder
|
||||
imapsieve_mailbox1_name = Junk
|
||||
imapsieve_mailbox1_causes = COPY
|
||||
|
||||
@@ -60,6 +60,7 @@ $_SESSION['acl']['sogo_access'] = "1";
|
||||
$_SESSION['acl']['protocol_access'] = "1";
|
||||
$_SESSION['acl']['mailbox_relayhost'] = "1";
|
||||
$_SESSION['acl']['unlimited_quota'] = "1";
|
||||
$_SESSION['acl']['archive'] = "1";
|
||||
|
||||
// One run at a time, released when the process ends
|
||||
$lock = fopen('/tmp/identity-sync.lock', 'c');
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
/watchdog@localhost$/ watchdog_discard:
|
||||
/^archive-bounce@localhost$/ archive_discard:
|
||||
/localhost$/ local:
|
||||
@@ -50,7 +50,9 @@ postscreen_greet_wait = 3s
|
||||
postscreen_non_smtp_command_enable = no
|
||||
postscreen_pipelining_enable = no
|
||||
proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_mail_policy_maps.cf,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_archive_sender_filter_maps.cf,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_archive_sasl_passwd_maps.cf,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_archive_tls_policy_maps.cf,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
|
||||
proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
||||
$sender_dependent_default_transport_maps,
|
||||
@@ -91,7 +93,6 @@ smtpd_hard_error_limit = ${stress?1}${stress:5}
|
||||
smtpd_helo_required = yes
|
||||
smtpd_proxy_timeout = 600s
|
||||
smtpd_recipient_restrictions = check_recipient_mx_access proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
|
||||
check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_mail_policy_maps.cf,
|
||||
permit_sasl_authenticated,
|
||||
permit_mynetworks,
|
||||
check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
||||
@@ -172,11 +173,5 @@ parent_domain_matches_subdomains = debug_peer_list,fast_flush_domains,mynetworks
|
||||
# This Option is added to correctly set the X-Original-To Header when mails are send to lmtp (dovecot)
|
||||
lmtp_destination_recipient_limit=1
|
||||
|
||||
# Mail policies: while policies or internal aliases exist, mysql_mail_policy_maps.cf returns this class and smtpd
|
||||
# asks the policy service for every recipient (mailcow-policy in master.cf); no answer defers the recipient
|
||||
smtpd_restriction_classes = mailcow_mail_policy
|
||||
mailcow_mail_policy = check_policy_service { unix:private/mailcow-policy, timeout=10s, try_limit=1, default_action=DEFER_IF_PERMIT }
|
||||
mailcow-policy_time_limit = 3600s
|
||||
|
||||
# DO NOT EDIT ANYTHING BELOW #
|
||||
# Overrides #
|
||||
@@ -61,13 +61,14 @@ submission inet n - n - - smtpd
|
||||
-o non_smtpd_milters=
|
||||
-o syslog_name=postfix/quarantine
|
||||
|
||||
# used to send bcc mails
|
||||
# used to send bcc mails and archive copies (sender archive-bounce@localhost goes to the archive)
|
||||
591 inet n - n - - smtpd
|
||||
-o smtpd_helo_restrictions=
|
||||
-o smtpd_client_restrictions=permit_mynetworks,reject
|
||||
-o smtpd_tls_auth_only=no
|
||||
-o smtpd_milters=
|
||||
-o non_smtpd_milters=
|
||||
-o smtpd_sender_restrictions=check_sender_access,proxy:mysql:/opt/postfix/conf/sql/mysql_archive_sender_filter_maps.cf,permit_mynetworks,reject
|
||||
-o syslog_name=postfix/bcc
|
||||
|
||||
# enforced smtp connector
|
||||
@@ -81,6 +82,27 @@ smtp_enforced_tls unix - - n - - smtp
|
||||
smtp_via_transport_maps unix - - n - - smtp
|
||||
-o smtp_sasl_password_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf
|
||||
|
||||
# archive copies (journal) to the archive set in the admin UI: only mails from archive-bounce@localhost on 591 get here
|
||||
# (FILTER of mysql_archive_sender_filter_maps), sent unchanged, TLS by default, refusals are retried (soft_bounce)
|
||||
# until maximal_queue_lifetime
|
||||
archive_smtp unix - - n - - smtp
|
||||
-o soft_bounce=yes
|
||||
-o smtp_header_checks=
|
||||
-o smtp_mime_header_checks=
|
||||
-o smtp_nested_header_checks=
|
||||
-o smtp_body_checks=
|
||||
-o smtp_line_length_limit=0
|
||||
-o disable_mime_output_conversion=yes
|
||||
-o smtp_tls_security_level=encrypt
|
||||
-o smtp_tls_policy_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_archive_tls_policy_maps.cf
|
||||
-o smtp_sasl_auth_enable=yes
|
||||
-o smtp_sasl_password_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_archive_sasl_passwd_maps.cf
|
||||
-o smtp_sasl_tls_security_options=noanonymous
|
||||
-o syslog_name=postfix/archive
|
||||
# notices about archive copies (delays, bounces) go to archive-bounce@localhost and are dropped here
|
||||
archive_discard unix - - n - - discard
|
||||
-o syslog_name=postfix/archive
|
||||
|
||||
tlsproxy unix - - n - 0 tlsproxy
|
||||
dnsblog unix - - n - 0 dnsblog
|
||||
pickup fifo n - n 60 1 pickup
|
||||
@@ -117,9 +139,6 @@ smtp_sender_cleanup unix n - y - 0 cleanup
|
||||
127.0.0.1:10027 inet n n n - 0 spawn user=nobody argv=/usr/local/bin/whitelist_forwardinghosts.sh
|
||||
# end whitelist_fwd
|
||||
|
||||
# mail policies: policy service of smtpd, asks the mailcow UI (mailcow_mail_policy in main.cf)
|
||||
mailcow-policy unix - n n - 0 spawn user=nobody argv=/usr/local/bin/mailcow-policy.py
|
||||
|
||||
# start watchdog-specific
|
||||
# logs to local7 (hidden)
|
||||
589 inet n - n - - smtpd
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
// Archive decision for an accepted mail, asked by the MAILCOW_ARCHIVE symbol of rspamd
|
||||
// Request: JSON {from, rcpt[], user, bcc, qid, received, sha256}; answer: JSON {archive, rcpt, sender, headers}
|
||||
header('Content-Type: application/json');
|
||||
require_once "vars.inc.php";
|
||||
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
|
||||
// Do not show errors, we log to using error_log
|
||||
ini_set('error_reporting', 0);
|
||||
// Init database
|
||||
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
try {
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
error_log("ARCHIVE: " . $e . PHP_EOL);
|
||||
http_response_code(501);
|
||||
exit;
|
||||
}
|
||||
// Init Redis, only to mirror the switch rspamd reads
|
||||
$redis = new Redis();
|
||||
try {
|
||||
$redis->connect('redis-mailcow', 6379);
|
||||
$redis->auth(getenv("REDISPASS"));
|
||||
}
|
||||
catch (Exception $e) {
|
||||
$redis = null;
|
||||
}
|
||||
|
||||
$request = json_decode(file_get_contents('php://input'), true);
|
||||
if (!is_array($request)) {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
|
||||
try {
|
||||
$archive = \Mailcow\Archive\ArchiveModule::create($pdo, \Mailcow\Core\Context::system(), $redis);
|
||||
echo json_encode($archive->journal->prepare($request));
|
||||
}
|
||||
catch (Throwable $e) {
|
||||
error_log("ARCHIVE: " . $e->getMessage() . PHP_EOL);
|
||||
http_response_code(502);
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<?php
|
||||
// Mail policy check for one recipient, asked by the Postfix policy service (mailcow-policy in master.cf)
|
||||
// Request: POST with the attributes of the Postfix policy request; answer: the action (DUNNO or the rejection)
|
||||
header('Content-Type: text/plain');
|
||||
require_once "vars.inc.php";
|
||||
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
|
||||
// Do not show errors, we log to using error_log
|
||||
ini_set('error_reporting', 0);
|
||||
// Init database
|
||||
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
try {
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
error_log("MAILPOLICY: " . $e . PHP_EOL);
|
||||
http_response_code(501);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isset($_POST['request']) || $_POST['request'] !== 'smtpd_access_policy') {
|
||||
http_response_code(400);
|
||||
exit;
|
||||
}
|
||||
$value = fn(string $key): string => is_string($_POST[$key] ?? null) ? $_POST[$key] : '';
|
||||
|
||||
try {
|
||||
$policy = \Mailcow\MailPolicy\MailPolicyModule::create($pdo, \Mailcow\Core\Context::system());
|
||||
echo $policy->api->check($value('sender'), $value('recipient'), $value('sasl_username'), (int)$value('server_port'), $value('helo_name'));
|
||||
}
|
||||
catch (Throwable $e) {
|
||||
// the policy service answers with a temporary error
|
||||
error_log("MAILPOLICY: " . $e->getMessage() . PHP_EOL);
|
||||
http_response_code(500);
|
||||
}
|
||||
@@ -469,5 +469,35 @@ while ($row = array_shift($rows)) {
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
// Start internal aliases
|
||||
|
||||
$stmt = $pdo->query("SELECT `id`, `address`, `domain` FROM `alias` WHERE `active` = '1' AND `internal` = '1'");
|
||||
$aliases = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
while ($alias = array_shift($aliases)) {
|
||||
// build allowed_domains regex and add target domain and alias domains
|
||||
$stmt = $pdo->prepare("SELECT `alias_domain` FROM `alias_domain` WHERE `active` = '1' AND `target_domain` = :target_domain");
|
||||
$stmt->execute(array(':target_domain' => $alias['domain']));
|
||||
$allowed_domains = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
$allowed_domains = array_map(function($item) {
|
||||
return str_replace('.', '\.', $item['alias_domain']);
|
||||
}, $allowed_domains);
|
||||
$allowed_domains[] = str_replace('.', '\.', $alias['domain']);
|
||||
$allowed_domains = implode('|', $allowed_domains);
|
||||
?>
|
||||
internal_alias_<?=$alias['id'];?> {
|
||||
priority = 10;
|
||||
rcpt = "<?=$alias['address'];?>";
|
||||
from = "/^((?!.*@(<?=$allowed_domains;?>)).)*$/";
|
||||
apply "default" {
|
||||
MAILCOW_INTERNAL_ALIAS = 9999.0;
|
||||
}
|
||||
symbols [
|
||||
"MAILCOW_INTERNAL_ALIAS"
|
||||
]
|
||||
}
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
}
|
||||
@@ -956,3 +956,138 @@ rspamd_config:register_symbol({
|
||||
end,
|
||||
priority = 1
|
||||
})
|
||||
|
||||
-- Archive: a copy of every accepted mail of an archived mailbox goes by SMTP to the archive, through Postfix (port 591)
|
||||
rspamd_config:register_symbol({
|
||||
name = 'MAILCOW_ARCHIVE',
|
||||
type = 'idempotent',
|
||||
flags = 'empty,explicit_disable,ignore_passthrough',
|
||||
callback = function(task)
|
||||
local rspamd_http = require "rspamd_http"
|
||||
local rspamd_logger = require "rspamd_logger"
|
||||
local rspamd_text = require "rspamd_text"
|
||||
local rspamd_util = require "rspamd_util"
|
||||
local hash = require "rspamd_cryptobox_hash"
|
||||
local lua_smtp = require "lua_smtp"
|
||||
local cjson = require "cjson"
|
||||
|
||||
-- accepted mails only: no reject, soft reject (greylisting included), discard or quarantine
|
||||
local action = task:get_metric_action('default')
|
||||
local accepted = (action == 'no action' or action == 'add header' or action == 'rewrite subject' or action == 'greylist')
|
||||
local pre_accept = false
|
||||
if task.has_pre_result then
|
||||
local has_pre, pre_action = task:has_pre_result()
|
||||
pre_accept = (has_pre and pre_action == 'accept')
|
||||
end
|
||||
if not accepted and not pre_accept then
|
||||
return
|
||||
end
|
||||
|
||||
local rcpts = {}
|
||||
for _, r in ipairs(task:get_recipients('smtp') or {}) do
|
||||
if r.addr and r.addr ~= '' then
|
||||
table.insert(rcpts, r.addr)
|
||||
end
|
||||
end
|
||||
if #rcpts == 0 then
|
||||
return
|
||||
end
|
||||
local from = task:get_from('smtp')
|
||||
local from_addr = (from and from[1] and from[1].addr) or ''
|
||||
local qid = task:get_queue_id() or ''
|
||||
-- the BCC symbol sends copies under the same condition, they bypass rspamd
|
||||
local bcc = (pre_accept or (action ~= 'greylist' and accepted)) and not task:has_symbol('ENCRYPTED_CHAT')
|
||||
local redis_params = rspamd_parse_redis_server('archive')
|
||||
|
||||
-- not archived: counted and listed for the archive status in the UI
|
||||
-- the Message-ID finds the mail in the archive log of the UI
|
||||
local message_id = task:get_message_id()
|
||||
if not message_id or message_id == 'undef' then
|
||||
message_id = ''
|
||||
end
|
||||
local function dropped(reason)
|
||||
rspamd_logger.errx(task, "archive: mail %s is not archived: %s", qid, reason)
|
||||
rspamd_redis_make_request(task, redis_params, 'ARCHIVE_DROPPED', true, function() end, 'INCR', {'ARCHIVE_DROPPED'})
|
||||
rspamd_redis_make_request(task, redis_params, 'ARCHIVE_DROPPED_LOG', true, function() end, 'LPUSH',
|
||||
{'ARCHIVE_DROPPED_LOG', cjson.encode({time = os.time(), qid = qid, reason = tostring(reason), message_id = message_id})})
|
||||
rspamd_redis_make_request(task, redis_params, 'ARCHIVE_DROPPED_LOG', true, function() end, 'LTRIM', {'ARCHIVE_DROPPED_LOG', '0', '99'})
|
||||
end
|
||||
|
||||
local function archive()
|
||||
-- the archive gets the mail as accepted, with CRLF line ends and a final CRLF (also what the hash covers)
|
||||
local content = rspamd_text.fromstring(tostring(task:get_content()))
|
||||
content:normalize_newlines('smtp')
|
||||
if #content < 2 or content:at(#content) ~= 10 or content:at(#content - 1) ~= 13 then
|
||||
content = rspamd_text.fromtable({content, '\r\n'})
|
||||
end
|
||||
local body = {
|
||||
from = from_addr,
|
||||
rcpt = rcpts,
|
||||
user = task:get_user() or '',
|
||||
bcc = bcc,
|
||||
qid = qid,
|
||||
received = rspamd_util.get_time(),
|
||||
sha256 = hash.create_specific('sha256', content):hex()
|
||||
}
|
||||
|
||||
local function sent_cb(ok, err)
|
||||
if not ok then
|
||||
dropped(err)
|
||||
end
|
||||
end
|
||||
|
||||
local function journal_cb(err, code, res_body)
|
||||
if err or code ~= 200 then
|
||||
dropped(string.format('owner lookup failed (%s, %s)', err, code))
|
||||
return
|
||||
end
|
||||
local ok, res = pcall(cjson.decode, tostring(res_body))
|
||||
if not ok or type(res) ~= 'table' then
|
||||
dropped('invalid answer of the owner lookup')
|
||||
return
|
||||
end
|
||||
if res.archive ~= true then
|
||||
return
|
||||
end
|
||||
-- lua_smtp ends the data with CRLF.CRLF, so the last CRLF of the content is left to it
|
||||
lua_smtp.sendmail({
|
||||
task = task,
|
||||
host = os.getenv("IPV4_NETWORK") .. '.253',
|
||||
port = 591,
|
||||
from = res.sender,
|
||||
recipients = res.rcpt,
|
||||
helo = 'archive',
|
||||
timeout = 20,
|
||||
}, {res.headers or '', content:span(1, #content - 2)}, sent_cb)
|
||||
end
|
||||
|
||||
rspamd_http.request({
|
||||
task = task,
|
||||
url = 'http://nginx:8081/archive.php',
|
||||
body = cjson.encode(body),
|
||||
timeout = (#rcpts > 100) and 10.0 or 2.0,
|
||||
callback = journal_cb,
|
||||
headers = {['Content-Type'] = 'application/json'},
|
||||
})
|
||||
end
|
||||
|
||||
local function enabled_cb(err, data)
|
||||
if not err and type(data) == 'string' and data == '0' then
|
||||
return
|
||||
end
|
||||
archive()
|
||||
end
|
||||
|
||||
local ret = rspamd_redis_make_request(task,
|
||||
redis_params, -- connect params
|
||||
'ARCHIVE_ENABLED', -- hash key
|
||||
false, -- is write
|
||||
enabled_cb, --callback
|
||||
'GET', -- command
|
||||
{'ARCHIVE_ENABLED'} -- arguments
|
||||
)
|
||||
if not ret then
|
||||
archive()
|
||||
end
|
||||
end
|
||||
})
|
||||
@@ -11,7 +11,6 @@ $_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');
|
||||
@@ -47,7 +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),
|
||||
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -13,6 +13,7 @@ $js_minifier->add('/web/js/site/admin.js');
|
||||
$js_minifier->add('/web/js/presets/rspamd.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
$js_minifier->add('/web/js/site/identity.js');
|
||||
$js_minifier->add('/web/js/site/archive.js');
|
||||
|
||||
// all domains
|
||||
$domains = mailbox('get', 'domains');
|
||||
@@ -116,6 +117,7 @@ $template_data = [
|
||||
'ui_theme' => customize('get', 'ui_theme'),
|
||||
'password_complexity' => password_complexity('get'),
|
||||
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
|
||||
'archive_settings' => \Mailcow\Archive\ArchiveModule::fromGlobals()->api->getSettings(),
|
||||
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
|
||||
'cors_settings' => $cors_settings,
|
||||
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
||||
|
||||
+279
-370
@@ -685,6 +685,11 @@ paths:
|
||||
is domain global address list active or not, it enables
|
||||
shared contacts accross domain in SOGo webmail
|
||||
type: boolean
|
||||
archive:
|
||||
description: >-
|
||||
archive mails of the mailboxes of this domain by default (mailboxes set to `inherit`),
|
||||
see GET /api/v1/get/archive/mailboxes
|
||||
type: boolean
|
||||
mailboxes:
|
||||
description: limit count of mailboxes associated with this domain
|
||||
type: number
|
||||
@@ -1254,6 +1259,12 @@ paths:
|
||||
force_tfa:
|
||||
description: force 2FA enrollment at login
|
||||
type: boolean
|
||||
archive:
|
||||
description: >-
|
||||
archiving of the mailbox: `inherit` follows the domain, `1` always, `0` never (admins, domain
|
||||
admins with the archive permission; otherwise the template decides)
|
||||
type: string
|
||||
enum: [inherit, "1", "0"]
|
||||
tls_enforce_in:
|
||||
description: force inbound email tls encryption
|
||||
type: boolean
|
||||
@@ -1365,7 +1376,7 @@ paths:
|
||||
description: show the group address as SOGo identity (needs sender_allowed)
|
||||
type: boolean
|
||||
internal:
|
||||
description: only accept mail from logged-in senders of the domain
|
||||
description: only accept mail from senders of the domain
|
||||
type: boolean
|
||||
template_id:
|
||||
description: mailbox template applied to the members (admin only), empty = none
|
||||
@@ -1377,115 +1388,6 @@ 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> of a group of the policy's domain (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), max. 500
|
||||
type: array
|
||||
groups:
|
||||
description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged
|
||||
type: array
|
||||
active:
|
||||
type: boolean
|
||||
type: object
|
||||
summary: Create mail policy
|
||||
/api/v1/add/oauth2-client:
|
||||
post:
|
||||
responses:
|
||||
@@ -2962,62 +2864,6 @@ 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:
|
||||
@@ -4083,6 +3929,11 @@ paths:
|
||||
is domain global address list active or not, it enables
|
||||
shared contacts accross domain in SOGo webmail
|
||||
type: boolean
|
||||
archive:
|
||||
description: >-
|
||||
archive mails of the mailboxes of this domain by default (admins, domain admins with the
|
||||
archive permission)
|
||||
type: boolean
|
||||
mailboxes:
|
||||
description: limit count of mailboxes associated with this domain
|
||||
type: number
|
||||
@@ -4387,6 +4238,12 @@ paths:
|
||||
force_tfa:
|
||||
description: force 2FA enrollment at login
|
||||
type: boolean
|
||||
archive:
|
||||
description: >-
|
||||
archiving of the mailbox: `inherit` follows the domain, `1` always, `0` never (admins, domain
|
||||
admins with the archive permission)
|
||||
type: string
|
||||
enum: [inherit, "1", "0"]
|
||||
name:
|
||||
description: Full name of the mailbox user
|
||||
type: string
|
||||
@@ -4587,7 +4444,7 @@ paths:
|
||||
description: show the group address as SOGo identity (needs sender_allowed)
|
||||
type: boolean
|
||||
internal:
|
||||
description: only accept mail from logged-in senders of the domain
|
||||
description: only accept mail from senders of the domain
|
||||
type: boolean
|
||||
template_id:
|
||||
description: mailbox template applied to the members (admin only), empty = none
|
||||
@@ -4662,106 +4519,6 @@ 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> of a group of the policy's domain (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), max. 500
|
||||
type: array
|
||||
groups:
|
||||
description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged
|
||||
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:
|
||||
@@ -4811,6 +4568,104 @@ paths:
|
||||
default: run
|
||||
type: object
|
||||
summary: Request identity sync run
|
||||
/api/v1/edit/archive:
|
||||
post:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
- log:
|
||||
- archive
|
||||
- edit
|
||||
- settings
|
||||
- enabled: "1"
|
||||
address: journal@archive.example.org
|
||||
signing_secret: "*"
|
||||
msg: archive_settings_saved
|
||||
type: success
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Archive
|
||||
description: >-
|
||||
Archive target, admins with a read-write key only. mailcow sends a copy of every accepted mail of archived
|
||||
mailboxes by SMTP to `address`, through the Postfix queue (envelope sender `archive-bounce@localhost`, notices
|
||||
to it are dropped: no bounce or delay warning reaches a sender). Attributes missing in the request keep their
|
||||
value, an empty `password` or `signing_secret` keeps the stored one. Switching on needs the address, and the
|
||||
signing key for the profile archivecow. With `action: test` the stored target is tested (connection, TLS,
|
||||
AUTH, whether the address is accepted; no mail is sent), with `action: testmail` a test copy is sent through
|
||||
the mail queue.
|
||||
operationId: Edit archive settings
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
example:
|
||||
items:
|
||||
- archive
|
||||
attr:
|
||||
enabled: "1"
|
||||
address: journal@archive.example.org
|
||||
host: archive.example.org
|
||||
port: 25
|
||||
tls: encrypt
|
||||
profile: archivecow
|
||||
signing_secret: secret-key
|
||||
properties:
|
||||
items:
|
||||
type: array
|
||||
default: ["archive"]
|
||||
attr:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
description: copies of accepted mails go to the archive; copies already queued are delivered either way
|
||||
type: boolean
|
||||
address:
|
||||
description: address the archive receives copies at
|
||||
type: string
|
||||
host:
|
||||
description: SMTP server of the archive, empty for the MX of the address domain
|
||||
type: string
|
||||
port:
|
||||
type: integer
|
||||
default: 25
|
||||
tls:
|
||||
description: "`encrypt` (TLS required), `verify` (TLS required, certificate verified), `may`"
|
||||
type: string
|
||||
enum: [encrypt, verify, may]
|
||||
username:
|
||||
description: SMTP AUTH user at the archive, empty for none
|
||||
type: string
|
||||
password:
|
||||
description: SMTP AUTH password, never returned
|
||||
type: string
|
||||
profile:
|
||||
description: >-
|
||||
headers in front of each copy: `archivecow` (X-Archive-Owner, -Rcpt, -From, -Queue-ID,
|
||||
-Received, -Direction, -Source, -SHA256 and X-Archive-Signature), `envelope` (sender and
|
||||
recipients under `header_from` / `header_rcpt`), `none`
|
||||
type: string
|
||||
enum: [archivecow, envelope, none]
|
||||
signing_secret:
|
||||
description: key of X-Archive-Signature (HMAC-SHA256), never returned
|
||||
type: string
|
||||
header_from:
|
||||
type: string
|
||||
default: X-Envelope-From
|
||||
header_rcpt:
|
||||
type: string
|
||||
default: X-Envelope-To
|
||||
action:
|
||||
type: string
|
||||
enum: [test, testmail]
|
||||
type: object
|
||||
summary: Edit archive settings
|
||||
/api/v1/edit/mailq:
|
||||
post:
|
||||
responses:
|
||||
@@ -6023,6 +5878,7 @@ paths:
|
||||
description: Some description
|
||||
domain_name: domain.tld
|
||||
gal: "0"
|
||||
archive: 1
|
||||
max_new_mailbox_quota: 10737418240
|
||||
max_num_aliases_for_domain: 400
|
||||
max_num_mboxes_for_domain: 10
|
||||
@@ -6577,7 +6433,9 @@ paths:
|
||||
response:
|
||||
value:
|
||||
- active: "1"
|
||||
archive: 1
|
||||
attributes:
|
||||
archive: inherit
|
||||
force_pw_update: "0"
|
||||
force_tfa: "0"
|
||||
mailbox_format: "maildir:"
|
||||
@@ -6720,107 +6578,6 @@ 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",
|
||||
"receive" or "address" (the recipient routes through another address),
|
||||
policy is empty for a policy of another domain (its text is then the
|
||||
default text), internal_alias = refused by the internal flag of an
|
||||
alias or group. Domain admins test addresses of their domains; a sender
|
||||
of another domain is tested as not logged in. Empty answer for invalid
|
||||
input.
|
||||
operationId: Test mail policies
|
||||
summary: Test mail policies
|
||||
/api/v1/get/identity-provider/sync-status:
|
||||
get:
|
||||
responses:
|
||||
@@ -6873,6 +6630,154 @@ paths:
|
||||
nothing was changed).
|
||||
operationId: Get identity sync status
|
||||
summary: Get identity sync status
|
||||
/api/v1/get/archive/mailboxes:
|
||||
get:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
enabled: 1
|
||||
mailboxes:
|
||||
- username: anna@example.org
|
||||
domain: example.org
|
||||
name: Anna Example
|
||||
active: 1
|
||||
source: domain
|
||||
- username: board@example.net
|
||||
domain: example.net
|
||||
name: Board
|
||||
active: 2
|
||||
source: mailbox
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Archive
|
||||
description: >-
|
||||
Mailboxes whose mails go to the archive now (admins, read-only keys included). A mailbox is archived when
|
||||
archiving is switched on (`enabled`) and its setting is `1`, or `inherit` while its domain archives by default
|
||||
(`source` says which). Only mailboxes that receive mail are listed: `active` 1, or 2 (no login, mail is
|
||||
delivered). `username` is the value of the X-Archive-Owner header. Mailboxes that drop out of the list
|
||||
(archiving switched off, deleted, renamed) keep their mails in the archive.
|
||||
operationId: Get archived mailboxes
|
||||
summary: Get archived mailboxes
|
||||
/api/v1/get/archive/status:
|
||||
get:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
enabled: 1
|
||||
configured: 1
|
||||
queued: 2
|
||||
queued_bytes: 182044
|
||||
oldest_queued: 1762425600
|
||||
delay_reasons:
|
||||
- "connect to archive.example.org[192.0.2.10]:25: Connection refused"
|
||||
last_delivery: 1762425580
|
||||
dropped: 3
|
||||
dropped_24h: 1
|
||||
last_dropped:
|
||||
time: 1762425500
|
||||
reason: "owner lookup failed (nil, 502)"
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Archive
|
||||
description: >-
|
||||
Summary of the archive state (admins, read-only keys included): copies waiting in the Postfix queue with the
|
||||
reasons Postfix gives, the time of the last delivery to the archive, and mails that were not archived because
|
||||
their copy could not be handed to Postfix (`dropped` since ever, `dropped_24h` and `last_dropped` for the last
|
||||
24 hours).
|
||||
operationId: Get archive status
|
||||
summary: Get archive status
|
||||
"/api/v1/get/logs/archive/{count}":
|
||||
get:
|
||||
parameters:
|
||||
- description: Number of logs to return
|
||||
in: path
|
||||
name: count
|
||||
required: true
|
||||
schema:
|
||||
type: number
|
||||
- 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:
|
||||
- time: 1762425580
|
||||
status: sent
|
||||
qid: 4XyZ1abc
|
||||
message_id: "c3d1e0f2@example.org"
|
||||
text: "250 2.0.0 Ok: queued as 9F2C4"
|
||||
- time: 1762425500
|
||||
status: dropped
|
||||
qid: 3AbC2def
|
||||
message_id: "a1b2c3@example.org"
|
||||
text: "owner lookup failed (nil, 502)"
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Logs
|
||||
description: >-
|
||||
Archive log, newest first (admins, read-only keys included): deliveries of copies from the Postfix log
|
||||
(`status` sent, deferred, softbounce when the archive refused the copy and Postfix keeps it, or bounced),
|
||||
copies discarded after the queue lifetime (expired) and mails that were not archived (dropped, `text` is the
|
||||
reason). `message_id` is the Message-ID of the mail, `qid` the queue ID of the copy (of the mail for dropped).
|
||||
|
||||
Tip: You can limit how many logs you want to get by using `/<count>` at
|
||||
the end of the api url.
|
||||
operationId: Get archive log
|
||||
summary: Get archive log
|
||||
/api/v1/get/archive/settings:
|
||||
get:
|
||||
responses:
|
||||
"401":
|
||||
$ref: "#/components/responses/Unauthorized"
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
examples:
|
||||
response:
|
||||
value:
|
||||
enabled: 1
|
||||
address: journal@archive.example.org
|
||||
host: archive.example.org
|
||||
port: 25
|
||||
tls: encrypt
|
||||
username: ""
|
||||
has_password: 0
|
||||
profile: archivecow
|
||||
has_signing_secret: 1
|
||||
header_from: X-Envelope-From
|
||||
header_rcpt: X-Envelope-To
|
||||
description: OK
|
||||
headers: {}
|
||||
tags:
|
||||
- Archive
|
||||
description: Archive target (admins, read-only keys included); password and signing key are never returned.
|
||||
operationId: Get archive settings
|
||||
summary: Get archive settings
|
||||
"/api/v1/get/identity-link/{username}":
|
||||
get:
|
||||
parameters:
|
||||
@@ -7963,7 +7868,9 @@ paths:
|
||||
response:
|
||||
value:
|
||||
- active: "1"
|
||||
archive: 1
|
||||
attributes:
|
||||
archive: inherit
|
||||
force_pw_update: "0"
|
||||
force_tfa: "0"
|
||||
mailbox_format: "maildir:"
|
||||
@@ -8377,8 +8284,6 @@ 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
|
||||
@@ -8393,3 +8298,7 @@ tags:
|
||||
description: Manage Cross-Origin Resource Sharing (CORS) settings
|
||||
- name: Identity Provider
|
||||
description: Manage external Identity Provider settings
|
||||
- name: Archive
|
||||
description: >-
|
||||
Archiving (SMTP journaling): a copy of every accepted incoming, outgoing and internal mail of archived
|
||||
mailboxes goes by SMTP to the archive, through the Postfix queue.
|
||||
@@ -11,7 +11,6 @@ $_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,7 +45,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),
|
||||
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
+5
-12
@@ -65,7 +65,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
'mta_sts' => $mta_sts,
|
||||
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
|
||||
'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'),
|
||||
'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"])
|
||||
'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"]),
|
||||
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled()
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -75,6 +76,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
$template_data = [
|
||||
'template' => $domain_template,
|
||||
'rl' => ['frame' => $domain_template['attributes']['rl_frame']],
|
||||
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||
];
|
||||
$template = 'edit/domain-templates.twig';
|
||||
$result = true;
|
||||
@@ -85,6 +87,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
$template_data = [
|
||||
'template' => $mailbox_template,
|
||||
'rl' => ['frame' => $mailbox_template['attributes']['rl_frame']],
|
||||
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||
];
|
||||
$template = 'edit/mailbox-templates.twig';
|
||||
$result = true;
|
||||
@@ -115,17 +118,6 @@ 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']);
|
||||
@@ -173,6 +165,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
'user_acls' => acl('get', 'user', $mailbox),
|
||||
'mailbox_details' => $result,
|
||||
'iam_settings' => $iam_settings,
|
||||
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,7 +74,6 @@ $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'],
|
||||
|
||||
@@ -539,6 +539,7 @@ function sys_mail($_data) {
|
||||
$mail->Body = $mass_text;
|
||||
}
|
||||
$mail->XMailer = 'MooMassMail';
|
||||
$archive = \Mailcow\Archive\ArchiveModule::fromGlobals();
|
||||
foreach ($rcpts as $rcpt) {
|
||||
$mail->AddAddress($rcpt);
|
||||
if (!$mail->send()) {
|
||||
@@ -548,6 +549,10 @@ function sys_mail($_data) {
|
||||
'msg' => 'Mailer error (RCPT "' . htmlspecialchars($rcpt) . '"): ' . str_replace('https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting', '', $mail->ErrorInfo)
|
||||
);
|
||||
}
|
||||
else {
|
||||
// Delivered by LMTP without rspamd, so the archive gets it here
|
||||
$archive->journal->submitDelivered($mail->getSentMIMEMessage(), $mass_from, array($rcpt), 'massmail');
|
||||
}
|
||||
$mail->ClearAllRecipients();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,6 +366,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_unknown_only'];
|
||||
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx'];
|
||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal'];
|
||||
$archive = (intval((isset($_data['archive'])) ? $_data['archive'] : ($DOMAIN_DEFAULT_ATTRIBUTES['archive'] ?? 0)) == 1) ? 1 : 0;
|
||||
if ($relay_all_recipients == 1) {
|
||||
$backupmx = '1';
|
||||
}
|
||||
@@ -421,8 +422,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
':domain' => '%@' . $domain
|
||||
));
|
||||
// save domain
|
||||
$stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`)
|
||||
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients)");
|
||||
$stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`, `archive`)
|
||||
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients, :archive)");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain,
|
||||
':description' => $description,
|
||||
@@ -435,7 +436,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
':gal' => $gal,
|
||||
':active' => $active,
|
||||
':relay_unknown_only' => $relay_unknown_only,
|
||||
':relay_all_recipients' => $relay_all_recipients
|
||||
':relay_all_recipients' => $relay_all_recipients,
|
||||
':archive' => $archive
|
||||
));
|
||||
// save tags
|
||||
foreach($tags as $index => $tag){
|
||||
@@ -940,6 +942,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0;
|
||||
// Shared mailbox: no login of its own, members open it through group shares
|
||||
$shared = (isset($_data['shared'])) ? intval($_data['shared']) : 0;
|
||||
// Archiving comes from the template unless the caller may set it
|
||||
$archive = \Mailcow\Archive\Model\Mailbox::setting((isset($_data['archive']) && hasACLAccess("archive")) ? $_data['archive'] : ($MAILBOX_DEFAULT_ATTRIBUTES['archive'] ?? 'inherit'));
|
||||
$quarantine_notification = (isset($_data['quarantine_notification'])) ? strval($_data['quarantine_notification']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']);
|
||||
$quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
|
||||
// Validate quarantine_category
|
||||
@@ -974,6 +978,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
'dav_access' => strval($dav_access),
|
||||
'relayhost' => strval($relayhost),
|
||||
'shared' => strval($shared),
|
||||
'archive' => $archive,
|
||||
'passwd_update' => time(),
|
||||
'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']),
|
||||
'quarantine_notification' => strval($quarantine_notification),
|
||||
@@ -1525,6 +1530,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
|
||||
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
|
||||
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
|
||||
$attr['archive'] = (isset($_data['archive']) && intval($_data['archive']) == 1) ? 1 : 0;
|
||||
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
||||
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
||||
|
||||
@@ -1600,6 +1606,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
|
||||
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
|
||||
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_tfa']);
|
||||
$attr["archive"] = \Mailcow\Archive\Model\Mailbox::setting($_data['archive'] ?? $MAILBOX_DEFAULT_ATTRIBUTES['archive']);
|
||||
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['sogo_access']);
|
||||
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : 1;
|
||||
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
|
||||
@@ -2432,7 +2439,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
if ($address != $is_now['address']) {
|
||||
// Send-as grants of groups follow the address
|
||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
|
||||
\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
|
||||
}
|
||||
}
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -2489,6 +2495,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
||||
$description = (!empty($_data['description']) && isset($_SESSION['acl']['domain_desc']) && $_SESSION['acl']['domain_desc'] == "1") ? $_data['description'] : $is_now['description'];
|
||||
(int)$relayhost = (isset($_data['relayhost']) && isset($_SESSION['acl']['domain_relayhost']) && $_SESSION['acl']['domain_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['relayhost']);
|
||||
$archive = (isset($_data['archive']) && hasACLAccess("archive")) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['archive']);
|
||||
$tags = (is_array($_data['tags']) ? $_data['tags'] : array());
|
||||
}
|
||||
else {
|
||||
@@ -2502,11 +2509,13 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
|
||||
$stmt = $pdo->prepare("UPDATE `domain` SET
|
||||
`description` = :description,
|
||||
`gal` = :gal
|
||||
`gal` = :gal,
|
||||
`archive` = :archive
|
||||
WHERE `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':description' => $description,
|
||||
':gal' => $gal,
|
||||
':archive' => $archive,
|
||||
':domain' => $domain
|
||||
));
|
||||
// save tags
|
||||
@@ -2539,6 +2548,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
||||
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx'];
|
||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
||||
$archive = (isset($_data['archive'])) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['archive']);
|
||||
$relay_all_recipients = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : $is_now['relay_all_recipients'];
|
||||
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $is_now['relay_unknown_only'];
|
||||
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : $is_now['relayhost'];
|
||||
@@ -2653,6 +2663,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
`relay_unknown_only` = :relay_unknown_only,
|
||||
`backupmx` = :backupmx,
|
||||
`gal` = :gal,
|
||||
`archive` = :archive,
|
||||
`active` = :active,
|
||||
`quota` = :quota,
|
||||
`defquota` = :defquota,
|
||||
@@ -2667,6 +2678,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
':relay_unknown_only' => $relay_unknown_only,
|
||||
':backupmx' => $backupmx,
|
||||
':gal' => $gal,
|
||||
':archive' => $archive,
|
||||
':active' => $active,
|
||||
':quota' => $quota,
|
||||
':defquota' => $defquota,
|
||||
@@ -2754,6 +2766,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
|
||||
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
|
||||
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
|
||||
$attr['archive'] = (isset($_data['archive'])) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['attributes']['archive'] ?? 0);
|
||||
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
||||
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
||||
|
||||
@@ -2815,6 +2828,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
(int)$eas_access = (isset($_data['eas_access']) && hasACLAccess("protocol_access")) ? intval($_data['eas_access']) : intval($is_now['attributes']['eas_access']);
|
||||
(int)$dav_access = (isset($_data['dav_access']) && hasACLAccess("protocol_access")) ? intval($_data['dav_access']) : intval($is_now['attributes']['dav_access']);
|
||||
(int)$relayhost = (isset($_data['relayhost']) && hasACLAccess("mailbox_relayhost")) ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']);
|
||||
$archive = \Mailcow\Archive\Model\Mailbox::setting((isset($_data['archive']) && hasACLAccess("archive")) ? $_data['archive'] : ($is_now['attributes']['archive'] ?? null));
|
||||
(int)$shared = (isset($_data['shared'])) ? intval($_data['shared']) : intval($is_now['attributes']['shared'] ?? 0);
|
||||
(int)$quota_m = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576);
|
||||
$name = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name'];
|
||||
@@ -3115,6 +3129,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
`attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
|
||||
`attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_access),
|
||||
`attributes` = JSON_SET(`attributes`, '$.dav_access', :dav_access),
|
||||
`attributes` = JSON_SET(`attributes`, '$.archive', :archive),
|
||||
`attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email),
|
||||
`attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
|
||||
WHERE `username` = :username");
|
||||
@@ -3132,6 +3147,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
':smtp_access' => $smtp_access,
|
||||
':eas_access' => $eas_access,
|
||||
':dav_access' => $dav_access,
|
||||
':archive' => $archive,
|
||||
':recovery_email' => $pw_recovery_email,
|
||||
':relayhost' => $relayhost,
|
||||
':shared' => strval($shared),
|
||||
@@ -3296,8 +3312,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
'mailbox_group_imap_grant' => ['owner'],
|
||||
'mailbox_group_sogo_grant' => ['owner'],
|
||||
'mailbox_group_sogo_acl' => ['c_uid'],
|
||||
'mailbox_group_subscription' => ['username', 'owner'],
|
||||
'mail_policy_member' => ['username']
|
||||
'mailbox_group_subscription' => ['username', 'owner']
|
||||
];
|
||||
foreach ($tables as $table => $columns) {
|
||||
foreach ($columns as $column) {
|
||||
@@ -3313,8 +3328,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username);
|
||||
// Group memberships and send-as grants follow, group aliases are rebuilt
|
||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->hooks->onMailboxRenamed($old_username, $new_username);
|
||||
// Address entries of mail policies follow
|
||||
\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($old_username, $new_username);
|
||||
|
||||
// Update c_uid, c_name and mail in _sogo_static_view table
|
||||
$pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username")
|
||||
@@ -3538,6 +3551,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : $is_now['rl_value'];
|
||||
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : $is_now['force_pw_update'];
|
||||
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : $is_now['force_tfa'];
|
||||
$attr["archive"] = \Mailcow\Archive\Model\Mailbox::setting($_data['archive'] ?? ($is_now['attributes']['archive'] ?? null));
|
||||
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : $is_now['sogo_access'];
|
||||
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : $is_now['active'];
|
||||
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : $is_now['tls_enforce_in'];
|
||||
@@ -4686,6 +4700,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
`relay_unknown_only`,
|
||||
`backupmx`,
|
||||
`gal`,
|
||||
`archive`,
|
||||
`active`
|
||||
FROM `domain` WHERE `domain`= :domain");
|
||||
$stmt->execute(array(
|
||||
@@ -4746,6 +4761,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$domaindata['backupmx_int'] = $row['backupmx'];
|
||||
$domaindata['gal'] = $row['gal'];
|
||||
$domaindata['gal_int'] = $row['gal'];
|
||||
$domaindata['archive'] = $row['archive'];
|
||||
$domaindata['rl'] = $rl;
|
||||
$domaindata['active'] = $row['active'];
|
||||
$domaindata['active_int'] = $row['active'];
|
||||
@@ -4833,6 +4849,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
if (preg_match('/y|yes/i', getenv('MASTER'))) {
|
||||
$stmt = $pdo->prepare("SELECT
|
||||
`domain`.`backupmx`,
|
||||
`domain`.`active` AS `domain_active`,
|
||||
`domain`.`archive` AS `domain_archive`,
|
||||
`mailbox`.`username`,
|
||||
`mailbox`.`name`,
|
||||
`mailbox`.`active`,
|
||||
@@ -4855,6 +4873,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
else {
|
||||
$stmt = $pdo->prepare("SELECT
|
||||
`domain`.`backupmx`,
|
||||
`domain`.`active` AS `domain_active`,
|
||||
`domain`.`archive` AS `domain_archive`,
|
||||
`mailbox`.`username`,
|
||||
`mailbox`.`name`,
|
||||
`mailbox`.`active`,
|
||||
@@ -4894,6 +4914,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$mailboxdata['created'] = $row['created'];
|
||||
$mailboxdata['modified'] = $row['modified'];
|
||||
$mailboxdata['authsource'] = ($row['authsource']) ? $row['authsource'] : 'mailcow';
|
||||
// Mails of the mailbox go to the archive: global switch, mailbox setting ("inherit" follows the domain), receives mail
|
||||
static $archive_enabled = null;
|
||||
if ($archive_enabled === null) {
|
||||
try {
|
||||
$archive_enabled = \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled();
|
||||
}
|
||||
catch (PDOException $e) {
|
||||
$archive_enabled = false;
|
||||
}
|
||||
}
|
||||
$mailboxdata['archive'] = ($archive_enabled && $row['active'] != 0 && $row['domain_active'] == 1 && $row['backupmx'] == 0
|
||||
&& \Mailcow\Archive\Model\Mailbox::effective($mailboxdata['attributes']['archive'] ?? null, $row['domain_archive'])) ? 1 : 0;
|
||||
|
||||
if ($mailboxdata['percent_in_use'] === '- ') {
|
||||
$mailboxdata['percent_class'] = "info";
|
||||
|
||||
@@ -154,6 +154,7 @@ function quarantine($_action, $_data = null) {
|
||||
$mail->addAttachment($msg_tmpf, $detail_row['qid'] . '.eml');
|
||||
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
||||
$mail->send();
|
||||
$released_msg = $mail->getSentMIMEMessage();
|
||||
unlink($msg_tmpf);
|
||||
}
|
||||
catch (phpmailerException $e) {
|
||||
@@ -170,6 +171,7 @@ function quarantine($_action, $_data = null) {
|
||||
}
|
||||
elseif ($release_format == 'raw') {
|
||||
$detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
|
||||
$released_msg = $detail_row['msg'];
|
||||
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
|
||||
$postfix_talk = array(
|
||||
@@ -213,6 +215,10 @@ function quarantine($_action, $_data = null) {
|
||||
}
|
||||
fclose($smtp_connection);
|
||||
}
|
||||
// Port 590 bypasses rspamd, so the archive gets released mails here
|
||||
if (isset($released_msg)) {
|
||||
\Mailcow\Archive\ArchiveModule::fromGlobals()->journal->submitDelivered($released_msg, $sender, array($detail_row['rcpt']), 'quarantine');
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE id = :id");
|
||||
$stmt->execute(array(
|
||||
':id' => $row['id']
|
||||
@@ -453,6 +459,7 @@ function quarantine($_action, $_data = null) {
|
||||
$mail->addAttachment($msg_tmpf, $row['qid'] . '.eml');
|
||||
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
||||
$mail->send();
|
||||
$released_msg = $mail->getSentMIMEMessage();
|
||||
unlink($msg_tmpf);
|
||||
}
|
||||
catch (phpmailerException $e) {
|
||||
@@ -467,6 +474,7 @@ function quarantine($_action, $_data = null) {
|
||||
}
|
||||
elseif ($release_format == 'raw') {
|
||||
$row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
|
||||
$released_msg = $row['msg'];
|
||||
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
|
||||
$postfix_talk = array(
|
||||
@@ -506,6 +514,11 @@ function quarantine($_action, $_data = null) {
|
||||
}
|
||||
fclose($smtp_connection);
|
||||
}
|
||||
// Port 590 bypasses rspamd, so the archive gets released mails here
|
||||
if (isset($released_msg)) {
|
||||
\Mailcow\Archive\ArchiveModule::fromGlobals()->journal->submitDelivered($released_msg, $sender, array($row['rcpt']), 'quarantine');
|
||||
unset($released_msg);
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE `id` = :id");
|
||||
$stmt->execute(array(
|
||||
':id' => $id
|
||||
|
||||
@@ -242,6 +242,7 @@ function init_db_schema()
|
||||
"gal" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"relay_all_recipients" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"relay_unknown_only" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
@@ -491,86 +492,6 @@ 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" => "MEDIUMTEXT NOT NULL",
|
||||
"send_block" => "MEDIUMTEXT NOT NULL",
|
||||
"receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
|
||||
"receive_allow" => "MEDIUMTEXT NOT NULL",
|
||||
"receive_block" => "MEDIUMTEXT 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",
|
||||
@@ -783,8 +704,7 @@ function init_db_schema()
|
||||
"group_id" => array("group_id")
|
||||
),
|
||||
"key" => array(
|
||||
"domain" => array("domain"),
|
||||
"internal" => array("internal", "active")
|
||||
"domain" => array("domain")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_alias_mailbox_group" => array(
|
||||
@@ -1135,7 +1055,7 @@ function init_db_schema()
|
||||
"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'",
|
||||
"mail_policies" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
@@ -1359,6 +1279,42 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"archive_settings" => array(
|
||||
"cols" => array(
|
||||
"name" => "VARCHAR(64) NOT NULL",
|
||||
"value" => "TEXT NOT NULL",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("name")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
// Archive target the Postfix maps read (FILTER of the copies on port 591, SASL, TLS policy), one row kept in line with archive_settings
|
||||
"archive_transport" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"address" => "VARCHAR(255) NOT NULL",
|
||||
"nexthop" => "VARCHAR(255) NOT NULL",
|
||||
"username" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"password" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"tls_policy" => "VARCHAR(64) NOT NULL DEFAULT 'encrypt'",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"key" => array(
|
||||
"address" => array("address"),
|
||||
"nexthop" => array("nexthop")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"bcc_maps" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
@@ -2091,6 +2047,7 @@ function init_db_schema()
|
||||
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.relayhost', \"0\") WHERE JSON_VALUE(`attributes`, '$.relayhost') IS NULL;");
|
||||
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_pw_update', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_pw_update') IS NULL;");
|
||||
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
|
||||
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.archive', \"inherit\") WHERE JSON_VALUE(`attributes`, '$.archive') IS NULL;");
|
||||
// admin attributes
|
||||
$pdo->query("UPDATE `admin` SET `attributes` = '{}' WHERE `attributes` = '' OR `attributes` IS NULL;");
|
||||
$pdo->query("UPDATE `admin` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
|
||||
@@ -2138,6 +2095,7 @@ function init_db_schema()
|
||||
"backupmx" => 0,
|
||||
"relay_all_recipients" => 0,
|
||||
"relay_unknown_only" => 0,
|
||||
"archive" => 0,
|
||||
"dkim_selector" => "dkim",
|
||||
"key_size" => 2048,
|
||||
"max_quota_for_domain" => 10240 * 1048576,
|
||||
@@ -2155,6 +2113,7 @@ function init_db_schema()
|
||||
"rl_value" => "",
|
||||
"force_pw_update" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_pw_update']),
|
||||
"force_tfa" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_tfa']),
|
||||
"archive" => strval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['archive']),
|
||||
"sogo_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sogo_access']),
|
||||
"active" => 1,
|
||||
"tls_enforce_in" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['tls_enforce_in']),
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive;
|
||||
|
||||
use Mailcow\Archive\Controller\ArchiveApiController;
|
||||
use Mailcow\Archive\Legacy\LegacyQueue;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\LookupRepository;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\HeaderBuilder;
|
||||
use Mailcow\Archive\Service\JournalService;
|
||||
use Mailcow\Archive\Service\MailboxListService;
|
||||
use Mailcow\Archive\Service\OwnerResolver;
|
||||
use Mailcow\Archive\Service\SettingsService;
|
||||
use Mailcow\Archive\Service\StatusService;
|
||||
use Mailcow\Archive\Smtp\SmtpClient;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\Context;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* Composition root of the archive module (archive copies by SMTP through Postfix).
|
||||
*
|
||||
* process_get_return(ArchiveModule::fromGlobals()->api->getMailboxes());
|
||||
* ArchiveModule::create($pdo, Context::system(), $redis)->journal->prepare($request); // rspamd dynmap
|
||||
* ArchiveModule::fromGlobals()->journal->submitDelivered($message, $from, $rcpts, 'quarantine');
|
||||
*/
|
||||
class ArchiveModule
|
||||
{
|
||||
/** Request keys whose values never reach a log */
|
||||
private const SECRET_KEYS = ['password', 'signing_secret'];
|
||||
|
||||
public function __construct(
|
||||
public readonly SettingsService $settings,
|
||||
public readonly JournalService $journal,
|
||||
public readonly StatusService $status,
|
||||
public readonly MailboxListService $mailboxes,
|
||||
public readonly ArchiveApiController $api
|
||||
) {
|
||||
}
|
||||
|
||||
/** @param ?object $redis Redis connection; without it nothing is read from or written to Redis */
|
||||
public static function create(PDO $pdo, Context $context, ?object $redis = null): self
|
||||
{
|
||||
$settingsRepo = new SettingsRepository($pdo);
|
||||
$lookup = new LookupRepository($pdo);
|
||||
$state = new ArchiveState($redis);
|
||||
$smtp = new SmtpClient();
|
||||
$settings = new SettingsService($settingsRepo, $lookup, $state, $smtp, $context);
|
||||
$journal = new JournalService($settingsRepo, new OwnerResolver($lookup), new HeaderBuilder(), $smtp, $state, $context);
|
||||
$status = new StatusService($settingsRepo, $state, new LegacyQueue(), $context);
|
||||
$mailboxes = new MailboxListService($lookup, $settingsRepo, $context);
|
||||
$api = new ArchiveApiController($settings, $status, $journal, $mailboxes, new ApiResponse(self::SECRET_KEYS));
|
||||
return new self($settings, $journal, $status, $mailboxes, $api);
|
||||
}
|
||||
|
||||
/** Legacy entry point for procedural callers: reads $pdo, $redis and $_SESSION; $context overrides the session caller */
|
||||
public static function fromGlobals(?Context $context = null): self
|
||||
{
|
||||
$redis = $GLOBALS['redis'] ?? null;
|
||||
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), is_object($redis) ? $redis : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Controller;
|
||||
|
||||
use Mailcow\Archive\Service\JournalService;
|
||||
use Mailcow\Archive\Service\MailboxListService;
|
||||
use Mailcow\Archive\Service\SettingsService;
|
||||
use Mailcow\Archive\Service\StatusService;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* HTTP API adapter for json_api.php (routes `archive/*`, `archive`).
|
||||
*
|
||||
* Write methods return what json_api passes to process_*_return() and write their messages via ApiResponse.
|
||||
* Messages are logged as ['archive', action, object, data].
|
||||
*/
|
||||
class ArchiveApiController
|
||||
{
|
||||
public function __construct(private SettingsService $settings, private StatusService $status, private JournalService $journal,
|
||||
private MailboxListService $mailboxes, private ApiResponse $response)
|
||||
{
|
||||
}
|
||||
|
||||
/** GET archive/settings */
|
||||
public function getSettings(): array
|
||||
{
|
||||
return $this->read(fn(): array => $this->settings->get()->toArray());
|
||||
}
|
||||
|
||||
/** GET archive/status */
|
||||
public function getStatus(): array
|
||||
{
|
||||
return $this->read(fn(): array => $this->status->status());
|
||||
}
|
||||
|
||||
/** GET logs/archive[/count] */
|
||||
public function getLog(int $count = 0): array
|
||||
{
|
||||
return $this->read(fn(): array => $this->status->log($count));
|
||||
}
|
||||
|
||||
/** GET archive/mailboxes */
|
||||
public function getMailboxes(): array
|
||||
{
|
||||
return $this->read(fn(): array => $this->mailboxes->archived());
|
||||
}
|
||||
|
||||
/** POST edit/archive: the settings; `action: test` tests the connection, `action: testmail` sends a test copy */
|
||||
public function edit(array $attr): bool
|
||||
{
|
||||
return match ($attr['action'] ?? null) {
|
||||
'test' => $this->handle('edit', 'test', [], fn(array $log): bool => $this->testResult($this->settings->test(), $log)),
|
||||
'testmail' => $this->handle('edit', 'testmail', [],
|
||||
fn(array $log): bool => $this->response->success(['archive_testmail_queued', htmlspecialchars($this->journal->testMail())], $log)),
|
||||
default => $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool {
|
||||
$this->settings->save($attr);
|
||||
return $this->response->success('archive_settings_saved', $log);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A missing extension is a warning: the archive works, but may refuse some mails */
|
||||
private function testResult(string|array $msg, array $log): bool
|
||||
{
|
||||
if (is_array($msg)) {
|
||||
$this->response->warning($msg, $log);
|
||||
return true;
|
||||
}
|
||||
return $this->response->success($msg, $log);
|
||||
}
|
||||
|
||||
/** A GET answer, empty when the caller may not read it */
|
||||
private function read(callable $work): array
|
||||
{
|
||||
try {
|
||||
return $work();
|
||||
} catch (UserError) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, string $object, mixed $data, callable $work): bool
|
||||
{
|
||||
$log = ['archive', $action, $object, $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Legacy;
|
||||
|
||||
/**
|
||||
* Bridge to the legacy mailq() (Postfix queue through dockerapi) until it is converted.
|
||||
*/
|
||||
class LegacyQueue
|
||||
{
|
||||
/**
|
||||
* @return array[] queue entries of $sender: queue_id, queue_name, arrival_time, message_size,
|
||||
* recipients ("address (delay reason)")
|
||||
*/
|
||||
public function entries(string $sender): array
|
||||
{
|
||||
if (!function_exists('mailq')) {
|
||||
return [];
|
||||
}
|
||||
$list = json_decode((string)mailq('get'), true);
|
||||
if (!is_array($list)) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter($list, fn(mixed $entry): bool => is_array($entry) && ($entry['sender'] ?? '') === $sender));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Model;
|
||||
|
||||
/**
|
||||
* What the archive learns about one mail besides its content: owners, SMTP envelope, origin and content hash.
|
||||
*/
|
||||
class Journal
|
||||
{
|
||||
public const DIRECTIONS = ['in', 'out', 'internal'];
|
||||
public const SOURCES = ['smtp', 'quarantine', 'massmail', 'test'];
|
||||
|
||||
/**
|
||||
* @param string[] $owners mailbox usernames the mail belongs to
|
||||
* @param string[] $rcpts SMTP envelope recipients as received (incl. BCC)
|
||||
* @param float $received time mailcow accepted the mail (unix time with milliseconds)
|
||||
* @param string $sha256 hash of the content as sent to the archive (CRLF line ends)
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly array $owners,
|
||||
public readonly array $rcpts,
|
||||
public readonly string $from,
|
||||
public readonly string $qid,
|
||||
public readonly string $direction,
|
||||
public readonly string $source,
|
||||
public readonly float $received,
|
||||
public readonly string $sha256
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Model;
|
||||
|
||||
/**
|
||||
* A mailbox that receives mail (regular kind, active 1 or 2, in an active domain that is no backup MX)
|
||||
* with its archive settings: the mailbox attribute `archive` ('1', '0' or 'inherit') and the domain default.
|
||||
*/
|
||||
class Mailbox
|
||||
{
|
||||
public const INHERIT = 'inherit';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $username,
|
||||
public readonly string $domain,
|
||||
public readonly string $archive,
|
||||
public readonly bool $domainArchive,
|
||||
public readonly string $name = '',
|
||||
public readonly int $active = 1
|
||||
) {
|
||||
}
|
||||
|
||||
public static function fromRow(array $row): self
|
||||
{
|
||||
return new self(
|
||||
strtolower((string)$row['username']),
|
||||
strtolower((string)$row['domain']),
|
||||
self::setting($row['archive'] ?? null),
|
||||
(int)($row['domain_archive'] ?? 0) === 1,
|
||||
(string)($row['name'] ?? ''),
|
||||
(int)($row['active'] ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
/** Mailbox setting as stored: '1', '0', anything else (also a missing attribute) is 'inherit' */
|
||||
public static function setting(mixed $value): string
|
||||
{
|
||||
$value = is_scalar($value) ? (string)$value : '';
|
||||
return in_array($value, ['0', '1'], true) ? $value : self::INHERIT;
|
||||
}
|
||||
|
||||
/** Archiving applies by the mailbox setting, 'inherit' follows the domain (without the global switch) */
|
||||
public static function effective(mixed $archive, mixed $domainArchive): bool
|
||||
{
|
||||
$archive = self::setting($archive);
|
||||
return ($archive === self::INHERIT) ? (int)$domainArchive === 1 : $archive === '1';
|
||||
}
|
||||
|
||||
public function archived(): bool
|
||||
{
|
||||
return self::effective($this->archive, $this->domainArchive);
|
||||
}
|
||||
|
||||
/** Why the mailbox is archived: its own setting or the domain default */
|
||||
public function source(): string
|
||||
{
|
||||
return ($this->archive === '1') ? 'mailbox' : 'domain';
|
||||
}
|
||||
|
||||
/** Entry of GET archive/mailboxes */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'username' => $this->username,
|
||||
'domain' => $this->domain,
|
||||
'name' => $this->name,
|
||||
'active' => $this->active,
|
||||
'source' => $this->source(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Model;
|
||||
|
||||
/**
|
||||
* The archive target (table `archive_settings`): archive copies of accepted mails go by SMTP to `address`,
|
||||
* through the Postfix queue (table `archive_transport`, service archive_smtp in master.cf).
|
||||
*/
|
||||
class Settings
|
||||
{
|
||||
public const TLS_MODES = ['encrypt', 'verify', 'may'];
|
||||
/** Metadata headers of a copy: archivecow (X-Archive-*, signed), envelope (sender/recipients), none */
|
||||
public const PROFILES = ['archivecow', 'envelope', 'none'];
|
||||
/** Envelope sender of copies; Postfix drops every notice to it (archive_discard), nothing reaches a real sender */
|
||||
public const SENDER = 'archive-bounce@localhost';
|
||||
|
||||
/** rspamd hands accepted mails to the archive */
|
||||
public bool $enabled = false;
|
||||
/** Address the archive receives copies at */
|
||||
public string $address = '';
|
||||
/** SMTP server of the archive; empty: MX of the address domain */
|
||||
public string $host = '';
|
||||
public int $port = 25;
|
||||
public string $tls = 'encrypt';
|
||||
/** SMTP AUTH at the archive, optional */
|
||||
public string $username = '';
|
||||
/** Never part of toArray() */
|
||||
public string $password = '';
|
||||
public string $profile = 'archivecow';
|
||||
/** Key of X-Archive-Signature (profile archivecow), never part of toArray() */
|
||||
public string $signingKey = '';
|
||||
/** Header names of the envelope profile */
|
||||
public string $headerFrom = 'X-Envelope-From';
|
||||
public string $headerRcpt = 'X-Envelope-To';
|
||||
|
||||
/** Copies can be sent: an address, and a signing key for archivecow */
|
||||
public function canDeliver(): bool
|
||||
{
|
||||
return $this->address !== '' && ($this->profile !== 'archivecow' || $this->signingKey !== '');
|
||||
}
|
||||
|
||||
/** Next hop for Postfix: [host]:port, else the domain of the address (MX lookup), with the port when not 25 */
|
||||
public function nexthop(): string
|
||||
{
|
||||
if ($this->host !== '') {
|
||||
$host = filter_var($this->host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6:' . $this->host : $this->host;
|
||||
return '[' . $host . ']:' . $this->port;
|
||||
}
|
||||
$domain = substr($this->address, (int)strrpos($this->address, '@') + 1);
|
||||
return ($this->port === 25) ? $domain : $domain . ':' . $this->port;
|
||||
}
|
||||
|
||||
/** API representation (GET archive/settings), without secrets */
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'enabled' => (int)$this->enabled,
|
||||
'address' => $this->address,
|
||||
'host' => $this->host,
|
||||
'port' => $this->port,
|
||||
'tls' => $this->tls,
|
||||
'username' => $this->username,
|
||||
'has_password' => (int)($this->password !== ''),
|
||||
'profile' => $this->profile,
|
||||
'has_signing_secret' => (int)($this->signingKey !== ''),
|
||||
'header_from' => $this->headerFrom,
|
||||
'header_rcpt' => $this->headerRcpt,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Redis;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
|
||||
/**
|
||||
* Redis side of the archive:
|
||||
* - ARCHIVE_ENABLED: the switch rspamd reads before it asks for owners, mirrored from the settings.
|
||||
* - ARCHIVE_DROPPED / ARCHIVE_DROPPED_LOG: copies that could not be handed to Postfix (rspamd.local.lua, release
|
||||
* hooks), counted and the last 100 listed.
|
||||
* - POSTFIX_MAILLOG: the Postfix log, read for the archive log: deliveries of the archive_smtp service and expired
|
||||
* copies, with the Message-ID of the mail (a copy keeps the Message-ID of the original).
|
||||
*
|
||||
* The connection is a plain object, so the class loads without the redis extension; without a connection
|
||||
* nothing is read or written.
|
||||
*/
|
||||
class ArchiveState
|
||||
{
|
||||
public const ENABLED = 'ARCHIVE_ENABLED';
|
||||
public const DROPPED = 'ARCHIVE_DROPPED';
|
||||
public const DROPPED_LOG = 'ARCHIVE_DROPPED_LOG';
|
||||
private const MAILLOG = 'POSTFIX_MAILLOG';
|
||||
|
||||
public function __construct(private ?object $redis)
|
||||
{
|
||||
}
|
||||
|
||||
public function setEnabled(bool $enabled): void
|
||||
{
|
||||
$this->redis?->set(self::ENABLED, $enabled ? '1' : '0');
|
||||
}
|
||||
|
||||
/** A mail was not archived: its copy could not be handed to Postfix */
|
||||
public function dropped(string $qid, string $reason, string $messageId = ''): void
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
return;
|
||||
}
|
||||
$this->redis->incr(self::DROPPED);
|
||||
$this->redis->lPush(self::DROPPED_LOG, json_encode(['time' => time(), 'qid' => $qid, 'reason' => $reason, 'message_id' => $messageId]));
|
||||
$this->redis->lTrim(self::DROPPED_LOG, 0, 99);
|
||||
}
|
||||
|
||||
/** @return array{count: int, recent: array[]} count since ever; recent: time, qid, reason, message_id; newest first */
|
||||
public function droppedStats(int $limit = 100): array
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
return ['count' => 0, 'recent' => []];
|
||||
}
|
||||
$recent = [];
|
||||
foreach ((array)$this->redis->lRange(self::DROPPED_LOG, 0, $limit - 1) as $line) {
|
||||
$entry = is_string($line) ? json_decode($line, true) : null;
|
||||
if (is_array($entry)) {
|
||||
$recent[] = ['time' => (int)($entry['time'] ?? 0), 'qid' => (string)($entry['qid'] ?? ''),
|
||||
'reason' => (string)($entry['reason'] ?? ''), 'message_id' => (string)($entry['message_id'] ?? '')];
|
||||
}
|
||||
}
|
||||
return ['count' => (int)$this->redis->get(self::DROPPED), 'recent' => $recent];
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive log, newest first: deliveries of copies (status sent|deferred|softbounce|bounced, text = answer of the
|
||||
* archive), expired copies (expired) and mails not archived (dropped, text = reason).
|
||||
*
|
||||
* @return array[] time, status, qid (of the copy, of the mail for dropped), message_id, text
|
||||
*/
|
||||
public function events(int $limit = 1000): array
|
||||
{
|
||||
if ($this->redis === null) {
|
||||
return [];
|
||||
}
|
||||
$events = [];
|
||||
$messageIds = [];
|
||||
foreach ((array)$this->redis->lRange(self::MAILLOG, 0, -1) as $line) {
|
||||
$entry = is_string($line) ? json_decode($line, true) : null;
|
||||
if (!is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
$program = (string)($entry['program'] ?? '');
|
||||
$message = (string)($entry['message'] ?? '');
|
||||
$time = (int)($entry['time'] ?? 0);
|
||||
if ($program === 'postfix/cleanup' && preg_match('/^([0-9A-Za-z]+): message-id=(\S*)/', $message, $m)) {
|
||||
$messageIds[$m[1]] = trim($m[2], '<>');
|
||||
}
|
||||
// the archive_discard lines are the dropped notices, not deliveries
|
||||
elseif ($program === 'postfix/archive/smtp' && preg_match('/^([0-9A-Za-z]+): to=<[^>]*>.*\bstatus=(\w+) \((.*)\)$/', $message, $m)) {
|
||||
$events[] = ['time' => $time, 'status' => strtolower($m[2]), 'qid' => $m[1], 'message_id' => '', 'text' => $m[3]];
|
||||
}
|
||||
elseif ($program === 'postfix/qmgr' && preg_match('/^([0-9A-Za-z]+): from=<' . preg_quote(Settings::SENDER, '/') . '>, status=expired/', $message, $m)) {
|
||||
$events[] = ['time' => $time, 'status' => 'expired', 'qid' => $m[1], 'message_id' => '', 'text' => ''];
|
||||
}
|
||||
}
|
||||
foreach ($events as &$event) {
|
||||
$event['message_id'] = $messageIds[$event['qid']] ?? '';
|
||||
}
|
||||
unset($event);
|
||||
foreach ($this->droppedStats()['recent'] as $dropped) {
|
||||
$events[] = ['time' => $dropped['time'], 'status' => 'dropped', 'qid' => $dropped['qid'],
|
||||
'message_id' => $dropped['message_id'], 'text' => $dropped['reason']];
|
||||
}
|
||||
usort($events, fn(array $a, array $b): int => $b['time'] <=> $a['time']);
|
||||
return array_slice($events, 0, $limit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Repository;
|
||||
|
||||
use Mailcow\Archive\Model\Mailbox;
|
||||
use Mailcow\Core\Repository;
|
||||
|
||||
/**
|
||||
* Read-only lookups of the address tables, with the same conditions as the Postfix maps
|
||||
* (data/Dockerfiles/postfix/postfix.sh), and the mailboxes with their archive settings.
|
||||
*/
|
||||
class LookupRepository extends Repository
|
||||
{
|
||||
/** Columns of a mailbox that receives mail; the domain must be active and no backup MX */
|
||||
private const MAILBOX = "SELECT m.`username`, m.`domain`, m.`name`, m.`active`,
|
||||
JSON_VALUE(m.`attributes`, '$.archive') AS `archive`, d.`archive` AS `domain_archive`
|
||||
FROM `mailbox` m
|
||||
JOIN `domain` d ON d.`domain` = m.`domain`
|
||||
WHERE m.`kind` = '' AND (m.`active` = '1' OR m.`active` = '2') AND d.`active` = '1' AND d.`backupmx` = '0'";
|
||||
|
||||
/** recipient_canonical_maps: new destination of an address or '@domain' */
|
||||
public function recipientMap(string $key): ?string
|
||||
{
|
||||
return $this->value("SELECT `new_dest` FROM `recipient_maps` WHERE `old_dest` = :key AND `active` = '1' LIMIT 1", $key);
|
||||
}
|
||||
|
||||
/** Targets of an alias row (aliases, catch-alls '@domain', groups, mailbox self rows) */
|
||||
public function aliasGoto(string $key): ?string
|
||||
{
|
||||
return $this->value("SELECT `goto` FROM `alias` WHERE `address` = :key AND (`active` = '1' OR `active` = '2') LIMIT 1", $key);
|
||||
}
|
||||
|
||||
/** Resources (location, thing, group) are discarded by Postfix */
|
||||
public function isResource(string $username): bool
|
||||
{
|
||||
return $this->value("SELECT `username` FROM `mailbox` WHERE `username` = :key AND `kind` REGEXP 'location|thing|group' LIMIT 1", $username) !== null;
|
||||
}
|
||||
|
||||
public function spamAliasGoto(string $key): ?string
|
||||
{
|
||||
return $this->value("SELECT `goto` FROM `spamalias` WHERE `address` = :key
|
||||
AND (`validity` >= UNIX_TIMESTAMP() OR `permanent` != 0) LIMIT 1", $key);
|
||||
}
|
||||
|
||||
/** Mailbox local@target behind local@domain when domain is an alias domain */
|
||||
public function aliasDomainMailbox(string $local, string $domain): ?string
|
||||
{
|
||||
$username = $this->query("SELECT `mailbox`.`username` FROM `mailbox`, `alias_domain`
|
||||
WHERE `alias_domain`.`alias_domain` = :domain AND `mailbox`.`username` = CONCAT(:local, '@', `alias_domain`.`target_domain`)
|
||||
AND (`mailbox`.`active` = '1' OR `mailbox`.`active` = '2') AND `alias_domain`.`active` = '1' LIMIT 1",
|
||||
['domain' => $domain, 'local' => $local])->fetchColumn();
|
||||
return is_string($username) ? $username : null;
|
||||
}
|
||||
|
||||
/** BCC destination of an address or '@domain' as rspamd's bcc.php finds it; $type 'rcpt' or 'sender' */
|
||||
public function bccDestination(string $key, string $type): ?string
|
||||
{
|
||||
$dest = $this->query("SELECT `bcc_dest` FROM `bcc_maps` WHERE `type` = :type AND `local_dest` = :key AND `active` = '1' LIMIT 1",
|
||||
['type' => $type, 'key' => $key])->fetchColumn();
|
||||
return is_string($dest) ? $dest : null;
|
||||
}
|
||||
|
||||
/** A domain or alias domain of this mailcow */
|
||||
public function isMailcowDomain(string $domain): bool
|
||||
{
|
||||
return $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :domain
|
||||
UNION SELECT `alias_domain` FROM `alias_domain` WHERE `alias_domain` = :alias LIMIT 1",
|
||||
['domain' => $domain, 'alias' => $domain])->fetchColumn() !== false;
|
||||
}
|
||||
|
||||
/** The mailbox when it receives mail */
|
||||
public function mailbox(string $username): ?Mailbox
|
||||
{
|
||||
$row = $this->query(self::MAILBOX . " AND m.`username` = :username", ['username' => $username])->fetch();
|
||||
return is_array($row) ? Mailbox::fromRow($row) : null;
|
||||
}
|
||||
|
||||
/** @return Mailbox[] mailboxes archived by their setting or their domain (without the global switch) */
|
||||
public function archivedMailboxes(): array
|
||||
{
|
||||
$rows = $this->query(self::MAILBOX . " AND (JSON_VALUE(m.`attributes`, '$.archive') = '1'
|
||||
OR (COALESCE(JSON_VALUE(m.`attributes`, '$.archive'), '') NOT IN ('0', '1') AND d.`archive` = '1'))
|
||||
ORDER BY m.`username`")->fetchAll();
|
||||
return array_map(fn(array $row): Mailbox => Mailbox::fromRow($row), $rows);
|
||||
}
|
||||
|
||||
private function value(string $sql, string $key): ?string
|
||||
{
|
||||
$value = $this->query($sql, ['key' => $key])->fetchColumn();
|
||||
return is_string($value) ? $value : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Repository;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Core\Repository;
|
||||
use PDO;
|
||||
|
||||
/**
|
||||
* SQL for the archive target: the settings (table `archive_settings`, name => value) and the row the Postfix maps
|
||||
* read (table `archive_transport`: transport, SASL, TLS policy of the archive_smtp service).
|
||||
*/
|
||||
class SettingsRepository extends Repository
|
||||
{
|
||||
/** Stored settings; keys that are not stored yet keep the defaults of the model */
|
||||
public function load(): Settings
|
||||
{
|
||||
$rows = $this->query("SELECT `name`, `value` FROM `archive_settings`")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$settings = new Settings();
|
||||
$settings->enabled = ($rows['enabled'] ?? '0') === '1';
|
||||
$settings->address = (string)($rows['address'] ?? '');
|
||||
$settings->host = (string)($rows['host'] ?? '');
|
||||
$settings->port = (int)($rows['port'] ?? $settings->port);
|
||||
$settings->tls = (string)($rows['tls'] ?? $settings->tls);
|
||||
$settings->username = (string)($rows['username'] ?? '');
|
||||
$settings->password = (string)($rows['password'] ?? '');
|
||||
$settings->profile = (string)($rows['profile'] ?? $settings->profile);
|
||||
$settings->signingKey = (string)($rows['signing_secret'] ?? '');
|
||||
$settings->headerFrom = (string)($rows['header_from'] ?? $settings->headerFrom);
|
||||
$settings->headerRcpt = (string)($rows['header_rcpt'] ?? $settings->headerRcpt);
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the settings and the transport row. The row stays while an address is set, also with archiving off,
|
||||
* so copies still queued in Postfix are delivered.
|
||||
*/
|
||||
public function save(Settings $settings): void
|
||||
{
|
||||
$values = [
|
||||
'enabled' => $settings->enabled ? '1' : '0',
|
||||
'address' => $settings->address,
|
||||
'host' => $settings->host,
|
||||
'port' => (string)$settings->port,
|
||||
'tls' => $settings->tls,
|
||||
'username' => $settings->username,
|
||||
'password' => $settings->password,
|
||||
'profile' => $settings->profile,
|
||||
'signing_secret' => $settings->signingKey,
|
||||
'header_from' => $settings->headerFrom,
|
||||
'header_rcpt' => $settings->headerRcpt,
|
||||
];
|
||||
$sql = "INSERT INTO `archive_settings` (`name`, `value`) VALUES (:name, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)";
|
||||
$this->transaction(function () use ($sql, $values, $settings): void {
|
||||
foreach ($values as $name => $value) {
|
||||
$this->query($sql, ['name' => $name, 'value' => $value]);
|
||||
}
|
||||
$this->query("DELETE FROM `archive_transport`");
|
||||
if ($settings->address !== '') {
|
||||
$this->insertRow('archive_transport', [
|
||||
'address' => $settings->address,
|
||||
'nexthop' => $settings->nexthop(),
|
||||
'username' => $settings->username,
|
||||
'password' => $settings->password,
|
||||
'tls_policy' => $settings->tls,
|
||||
'active' => 1,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Service;
|
||||
|
||||
use DateTime;
|
||||
use Mailcow\Archive\Model\Journal;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
|
||||
/**
|
||||
* Metadata header block in front of an archive copy, by profile:
|
||||
*
|
||||
* - archivecow: X-Archive-Owner, -Rcpt, -From, -Queue-ID, -Received, -Direction, -Source, -SHA256 and as the last
|
||||
* line X-Archive-Signature: v=1; a=hmac-sha256; s=<hex>. The signature is HMAC-SHA256 with the signing key over
|
||||
* the lines above it, each as "<lower case name>:<value without any whitespace>", joined by "\n".
|
||||
* The block ends with the signature line, everything after it is the mail as accepted (CRLF line ends).
|
||||
* - envelope: SMTP sender and recipients under configurable names (default X-Envelope-From / X-Envelope-To).
|
||||
* - none: no headers.
|
||||
*
|
||||
* Empty values are left out. Lists are folded after commas (CRLF + space).
|
||||
*/
|
||||
class HeaderBuilder
|
||||
{
|
||||
/** Fold a list before a line would get longer */
|
||||
private const FOLD_AT = 78;
|
||||
|
||||
public function build(Settings $settings, Journal $journal): string
|
||||
{
|
||||
return match ($settings->profile) {
|
||||
'archivecow' => $this->archivecow($settings, $journal),
|
||||
'envelope' => $this->envelope($settings, $journal),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
/** One line of the signed text */
|
||||
public static function canonical(string $name, string $value): string
|
||||
{
|
||||
return strtolower($name) . ':' . preg_replace('/\s+/', '', $value);
|
||||
}
|
||||
|
||||
private function archivecow(Settings $settings, Journal $journal): string
|
||||
{
|
||||
$values = [
|
||||
'X-Archive-Owner' => $journal->owners,
|
||||
'X-Archive-Rcpt' => $journal->rcpts,
|
||||
'X-Archive-From' => $journal->from,
|
||||
'X-Archive-Queue-ID' => $journal->qid,
|
||||
'X-Archive-Received' => self::time($journal->received),
|
||||
'X-Archive-Direction' => $journal->direction,
|
||||
'X-Archive-Source' => $journal->source,
|
||||
'X-Archive-SHA256' => $journal->sha256,
|
||||
];
|
||||
$lines = [];
|
||||
$signed = [];
|
||||
foreach ($values as $name => $value) {
|
||||
$value = is_array($value) ? self::cleanList($value) : self::clean($value);
|
||||
if ($value === '' || $value === []) {
|
||||
continue;
|
||||
}
|
||||
$lines[] = self::line($name, $value);
|
||||
$signed[] = self::canonical($name, is_array($value) ? implode(',', $value) : $value);
|
||||
}
|
||||
$lines[] = 'X-Archive-Signature: v=1; a=hmac-sha256; s=' . hash_hmac('sha256', implode("\n", $signed), $settings->signingKey);
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
|
||||
private function envelope(Settings $settings, Journal $journal): string
|
||||
{
|
||||
$from = self::clean($journal->from);
|
||||
$lines = [self::line($settings->headerFrom, ($from === '') ? '<>' : $from)];
|
||||
$rcpts = self::cleanList($journal->rcpts);
|
||||
if ($rcpts !== []) {
|
||||
$lines[] = self::line($settings->headerRcpt, $rcpts);
|
||||
}
|
||||
return implode("\r\n", $lines) . "\r\n";
|
||||
}
|
||||
|
||||
/** Header line; a list is folded after commas */
|
||||
private static function line(string $name, string|array $value): string
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $name . ': ' . $value;
|
||||
}
|
||||
$out = $name . ': ' . array_shift($value);
|
||||
$length = strlen($out);
|
||||
foreach ($value as $item) {
|
||||
if ($length + strlen($item) + 1 > self::FOLD_AT) {
|
||||
$out .= ",\r\n " . $item;
|
||||
$length = strlen($item) + 1;
|
||||
} else {
|
||||
$out .= ',' . $item;
|
||||
$length += strlen($item) + 1;
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Header value without control characters (no header injection) */
|
||||
private static function clean(string $value): string
|
||||
{
|
||||
return trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $value));
|
||||
}
|
||||
|
||||
/** @return string[] */
|
||||
private static function cleanList(array $values): array
|
||||
{
|
||||
$list = [];
|
||||
foreach ($values as $value) {
|
||||
$value = is_string($value) ? str_replace(',', '', self::clean($value)) : '';
|
||||
if ($value !== '') {
|
||||
$list[] = $value;
|
||||
}
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
/** RFC 3339 in UTC with milliseconds */
|
||||
private static function time(float $time): string
|
||||
{
|
||||
$date = DateTime::createFromFormat('U.u', sprintf('%.3F', $time));
|
||||
return $date ? $date->format('Y-m-d\TH:i:s.v\Z') : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Journal;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Smtp\SmtpClient;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Archive copies: a copy of every accepted mail of an archived mailbox goes by SMTP to the archive, through the
|
||||
* Postfix queue. rspamd sends the copies of mails it scanned (rspamd.local.lua) and asks prepare() whether to send
|
||||
* one and for its header block; mails that bypass rspamd (quarantine release, mass mail) are sent here.
|
||||
*
|
||||
* Copies have the envelope sender Settings::SENDER, Postfix drops every notice to it: an archive copy never causes
|
||||
* a bounce or delay warning to a real sender.
|
||||
*/
|
||||
class JournalService
|
||||
{
|
||||
/** Postfix service that takes copies (no milter, mynetworks only) */
|
||||
private const SUBMIT_PORT = 591;
|
||||
|
||||
public function __construct(
|
||||
private SettingsRepository $settings,
|
||||
private OwnerResolver $resolver,
|
||||
private HeaderBuilder $headers,
|
||||
private SmtpClient $smtp,
|
||||
private ArchiveState $state,
|
||||
private Context $context,
|
||||
private string $postfixHost = 'postfix'
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Archive decision for a mail rspamd accepted (dynmap archive.php), no copy while archiving is off or no
|
||||
* archived mailbox is involved.
|
||||
*
|
||||
* @param array $request from, rcpt[], user, bcc, qid, received, sha256 (of the content rspamd sends)
|
||||
* @return array{archive: bool, rcpt?: string, sender?: string, headers?: string}
|
||||
*/
|
||||
public function prepare(array $request): array
|
||||
{
|
||||
$settings = $this->settings->load();
|
||||
$this->state->setEnabled($settings->enabled);
|
||||
if (!$settings->enabled || !$settings->canDeliver()) {
|
||||
return ['archive' => false];
|
||||
}
|
||||
$from = self::text($request['from'] ?? '');
|
||||
$rcpts = is_array($request['rcpt'] ?? null) ? array_values(array_filter($request['rcpt'], 'is_string')) : [];
|
||||
$resolved = $this->resolver->resolve($from, $rcpts, self::text($request['user'] ?? ''), ($request['bcc'] ?? false) === true);
|
||||
if ($resolved['owners'] === []) {
|
||||
return ['archive' => false];
|
||||
}
|
||||
$sha256 = self::text($request['sha256'] ?? '');
|
||||
$journal = new Journal($resolved['owners'], $rcpts, $from, mb_strcut(self::text($request['qid'] ?? ''), 0, 64),
|
||||
$resolved['direction'], 'smtp', is_numeric($request['received'] ?? null) ? (float)$request['received'] : microtime(true),
|
||||
preg_match('/^[0-9a-f]{64}$/', $sha256) ? $sha256 : '');
|
||||
return ['archive' => true, 'rcpt' => $settings->address, 'sender' => Settings::SENDER, 'headers' => $this->headers->build($settings, $journal)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the copy of a mail that reached its mailbox without rspamd (quarantine release, mass mail) for its
|
||||
* archived recipients. Errors are logged and counted, never thrown: the mail is delivered already.
|
||||
*
|
||||
* @param string[] $rcpts
|
||||
*/
|
||||
public function submitDelivered(string $message, string $from, array $rcpts, string $source): bool
|
||||
{
|
||||
try {
|
||||
$settings = $this->settings->load();
|
||||
if (!$settings->enabled || !$settings->canDeliver()) {
|
||||
return false;
|
||||
}
|
||||
$resolved = $this->resolver->resolve($from, $rcpts, '', false);
|
||||
if ($resolved['owners'] === []) {
|
||||
return false;
|
||||
}
|
||||
$this->send($settings, $message, $resolved['owners'], array_values($rcpts), $from, 'in', $source);
|
||||
return true;
|
||||
} catch (Throwable $e) {
|
||||
error_log('ARCHIVE: cannot send the archive copy (' . $source . '): ' . $e->getMessage());
|
||||
$this->state->dropped('', $source . ': ' . $e->getMessage(), self::messageId($message));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a test copy through Postfix to the archive and returns the Postfix queue id.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function testMail(): string
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
$settings = $this->settings->load();
|
||||
if (!$settings->canDeliver()) {
|
||||
throw new UserError('archive_incomplete');
|
||||
}
|
||||
$message = implode("\r\n", [
|
||||
'From: mailcow <' . Settings::SENDER . '>',
|
||||
'To: <' . $settings->address . '>',
|
||||
'Subject: mailcow archive test',
|
||||
'Date: ' . date(DATE_RFC2822),
|
||||
'Message-ID: <' . bin2hex(random_bytes(12)) . '@archive-test.mailcow>',
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: text/plain; charset=utf-8',
|
||||
'',
|
||||
'Test copy from mailcow (System - Configuration - Archive).',
|
||||
'',
|
||||
]);
|
||||
try {
|
||||
return $this->send($settings, $message, [], [$settings->address], Settings::SENDER, 'in', 'test');
|
||||
} catch (SmtpException $e) {
|
||||
throw new UserError('archive_testmail_failed', [htmlspecialchars($e->describe())]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @throws SmtpException */
|
||||
private function send(Settings $settings, string $message, array $owners, array $rcpts, string $from, string $direction, string $source): string
|
||||
{
|
||||
$message = SmtpClient::crlf($message);
|
||||
$journal = new Journal($owners, $rcpts, $from, '', $direction, $source, microtime(true), hash('sha256', $message));
|
||||
return $this->smtp->submit($this->postfixHost, self::SUBMIT_PORT, Settings::SENDER, $settings->address,
|
||||
$this->headers->build($settings, $journal) . $message);
|
||||
}
|
||||
|
||||
/** Message-ID of a mail, for the archive log */
|
||||
private static function messageId(string $message): string
|
||||
{
|
||||
$headers = explode("\n\n", str_replace("\r\n", "\n", $message), 2)[0];
|
||||
return preg_match('/^Message-ID:\s*<?([^>\s]+)>?/mi', $headers, $m) ? $m[1] : '';
|
||||
}
|
||||
|
||||
private static function text(mixed $value): string
|
||||
{
|
||||
return is_scalar($value) ? trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', (string)$value)) : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Mailbox;
|
||||
use Mailcow\Archive\Repository\LookupRepository;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
|
||||
/**
|
||||
* The mailboxes archived now (GET archive/mailboxes), the list archivecow keeps its mailboxes in line with.
|
||||
* Same rule as the owners of a mail: the global switch, then the mailbox setting, 'inherit' follows the domain.
|
||||
*/
|
||||
class MailboxListService
|
||||
{
|
||||
public function __construct(private LookupRepository $lookup, private SettingsRepository $settings, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Admins, read-only API keys included.
|
||||
*
|
||||
* @return array{enabled: int, mailboxes: array[]}
|
||||
* @throws AccessDenied
|
||||
*/
|
||||
public function archived(): array
|
||||
{
|
||||
$this->context->requireAdmin(false);
|
||||
$enabled = $this->settings->load()->enabled;
|
||||
return [
|
||||
'enabled' => (int)$enabled,
|
||||
'mailboxes' => $enabled ? array_map(fn(Mailbox $mailbox): array => $mailbox->toArray(), $this->lookup->archivedMailboxes()) : [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Mailbox;
|
||||
use Mailcow\Archive\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Finds the mailboxes a mail belongs to in the archive, following the way Postfix delivers it.
|
||||
*
|
||||
* Incoming: each envelope recipient goes through recipient_maps (one rewrite) and the virtual alias expansion:
|
||||
* alias rows (incl. catch-alls and groups), resources (discarded), spam aliases and mailboxes of alias domains,
|
||||
* each tried as u+ext@d, u@d and @d, recursively. The local mailboxes at the end receive the mail. BCC map
|
||||
* destinations count as recipients when rspamd sends the BCC copy (it bypasses rspamd).
|
||||
*
|
||||
* Outgoing: the SASL user and the mailbox the user sent as (its address, with +tag or in an alias domain);
|
||||
* Postfix already checked that the login may use that sender. Without a login the sender never counts, so a
|
||||
* forged local sender cannot make a mailbox an owner.
|
||||
*
|
||||
* Only mailboxes with archiving become owners.
|
||||
*/
|
||||
class OwnerResolver
|
||||
{
|
||||
/** virtual_alias_recursion_limit and virtual_alias_expansion_limit of Postfix */
|
||||
private const MAX_DEPTH = 1000;
|
||||
private const MAX_ADDRESSES = 1000;
|
||||
|
||||
public function __construct(private LookupRepository $lookup)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string[] $rcpts SMTP envelope recipients
|
||||
* @param bool $bcc rspamd sent the BCC copies of this mail
|
||||
* @return array{owners: string[], direction: string} sorted usernames; direction in, out or internal
|
||||
*/
|
||||
public function resolve(string $from, array $rcpts, string $user, bool $bcc): array
|
||||
{
|
||||
$from = self::address($from);
|
||||
$incoming = [];
|
||||
foreach ($rcpts as $rcpt) {
|
||||
$rcpt = self::address($rcpt);
|
||||
if ($rcpt !== '') {
|
||||
$this->deliver($rcpt, $incoming);
|
||||
}
|
||||
}
|
||||
if ($bcc) {
|
||||
foreach ($this->bccDestinations($from, $rcpts) as $destination) {
|
||||
$this->deliver($destination, $incoming);
|
||||
}
|
||||
}
|
||||
$outgoing = $this->senders($from, self::address($user));
|
||||
$owners = [];
|
||||
foreach (array_merge(array_values($incoming), array_values($outgoing)) as $mailbox) {
|
||||
if ($mailbox->archived()) {
|
||||
$owners[$mailbox->username] = true;
|
||||
}
|
||||
}
|
||||
$owners = array_keys($owners);
|
||||
sort($owners);
|
||||
$direction = empty($outgoing) ? 'in' : (empty($incoming) ? 'out' : 'internal');
|
||||
return ['owners' => $owners, 'direction' => $direction];
|
||||
}
|
||||
|
||||
/** Adds the local mailboxes a recipient ends in to $mailboxes (username => Mailbox) */
|
||||
private function deliver(string $address, array &$mailboxes): void
|
||||
{
|
||||
$seen = [];
|
||||
$this->expand($this->canonical($address), 0, $mailboxes, $seen);
|
||||
}
|
||||
|
||||
/** recipient_maps: first matching key form, one rewrite */
|
||||
private function canonical(string $address): string
|
||||
{
|
||||
foreach (self::keys($address) as $key) {
|
||||
$target = self::target($this->lookup->recipientMap($key));
|
||||
if ($target !== '') {
|
||||
return self::rewrite($address, $key, $target);
|
||||
}
|
||||
}
|
||||
return $address;
|
||||
}
|
||||
|
||||
private function expand(string $address, int $depth, array &$mailboxes, array &$seen): void
|
||||
{
|
||||
if ($depth > self::MAX_DEPTH || count($seen) >= self::MAX_ADDRESSES || isset($seen[$address])) {
|
||||
return;
|
||||
}
|
||||
$seen[$address] = true;
|
||||
$targets = $this->virtual($address);
|
||||
if ($targets === null) {
|
||||
$this->final($address, $mailboxes);
|
||||
return;
|
||||
}
|
||||
foreach ($targets as $target) {
|
||||
// an address that expands to itself is delivered as it is
|
||||
if ($target === $address) {
|
||||
$this->final($address, $mailboxes);
|
||||
} else {
|
||||
$this->expand($target, $depth + 1, $mailboxes, $seen);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Targets from the first key form a virtual alias map knows, null if none does */
|
||||
private function virtual(string $address): ?array
|
||||
{
|
||||
if (self::split($address)[1] === 'localhost') {
|
||||
// null@, spam@, ham@localhost: local delivery of Postfix, never a mailbox
|
||||
return [];
|
||||
}
|
||||
foreach (self::keys($address) as $key) {
|
||||
$targets = $this->virtualLookup($key);
|
||||
if ($targets !== null) {
|
||||
return array_map(fn(string $target): string => self::rewrite($address, $key, $target), $targets);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** virtual_alias_maps in the order of main.cf: alias, resource, spam alias, alias domain mailbox */
|
||||
private function virtualLookup(string $key): ?array
|
||||
{
|
||||
$domainKey = str_starts_with($key, '@');
|
||||
$targets = self::targets($this->lookup->aliasGoto($key));
|
||||
if ($targets === null && !$domainKey && $this->lookup->isResource($key)) {
|
||||
$targets = ['null@localhost'];
|
||||
}
|
||||
$targets ??= self::targets($this->lookup->spamAliasGoto($key));
|
||||
if ($targets === null && !$domainKey) {
|
||||
[$local, $domain] = self::split($key);
|
||||
$targets = self::targets($this->lookup->aliasDomainMailbox($local, $domain));
|
||||
}
|
||||
return $targets;
|
||||
}
|
||||
|
||||
/** A final address: a mailbox (as u+ext@d or u@d) or anything else, which is not ours */
|
||||
private function final(string $address, array &$mailboxes): void
|
||||
{
|
||||
foreach (array_unique([$address, self::untagged($address)]) as $candidate) {
|
||||
$mailbox = $this->lookup->mailbox($candidate);
|
||||
if ($mailbox !== null) {
|
||||
$mailboxes[$mailbox->username] = $mailbox;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** BCC destinations of rspamd: rcpt maps for every recipient and its domain, sender maps for MAIL FROM and its domain */
|
||||
private function bccDestinations(string $from, array $rcpts): array
|
||||
{
|
||||
$destinations = [];
|
||||
foreach ($rcpts as $rcpt) {
|
||||
$rcpt = self::address($rcpt);
|
||||
if ($rcpt !== '') {
|
||||
$destinations[] = $this->lookup->bccDestination(self::untagged($rcpt), 'rcpt');
|
||||
$destinations[] = $this->lookup->bccDestination('@' . self::split($rcpt)[1], 'rcpt');
|
||||
}
|
||||
}
|
||||
if ($from !== '') {
|
||||
$destinations[] = $this->lookup->bccDestination(self::untagged($from), 'sender');
|
||||
$destinations[] = $this->lookup->bccDestination('@' . self::split($from)[1], 'sender');
|
||||
}
|
||||
return array_values(array_unique(array_filter(array_map(fn(?string $address): string => self::address($address), $destinations))));
|
||||
}
|
||||
|
||||
/** Mailboxes on the sending side: the SASL user and the mailbox the user sent as */
|
||||
private function senders(string $from, string $user): array
|
||||
{
|
||||
$mailbox = ($user !== '') ? $this->lookup->mailbox($user) : null;
|
||||
if ($mailbox === null) {
|
||||
return [];
|
||||
}
|
||||
$senders = [$mailbox->username => $mailbox];
|
||||
$sentAs = ($from !== '') ? $this->sentAs(self::untagged($from)) : null;
|
||||
if ($sentAs !== null) {
|
||||
$senders[$sentAs->username] = $sentAs;
|
||||
}
|
||||
return $senders;
|
||||
}
|
||||
|
||||
/** Mailbox a sender address belongs to: the mailbox itself or the mailbox behind an alias domain address, not aliases */
|
||||
private function sentAs(string $address): ?Mailbox
|
||||
{
|
||||
$mailbox = $this->lookup->mailbox($address);
|
||||
if ($mailbox === null) {
|
||||
[$local, $domain] = self::split($address);
|
||||
$username = $this->lookup->aliasDomainMailbox($local, $domain);
|
||||
$mailbox = ($username !== null) ? $this->lookup->mailbox(strtolower($username)) : null;
|
||||
}
|
||||
return $mailbox;
|
||||
}
|
||||
|
||||
/** Lookup keys in the order of Postfix: u+ext@d, u@d, @d */
|
||||
private static function keys(string $address): array
|
||||
{
|
||||
$keys = [$address, self::untagged($address), '@' . self::split($address)[1]];
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of a lookup for $address under $key: '@domain' keeps the local part, a match without the extension
|
||||
* passes it on (propagate_unmatched_extensions).
|
||||
*/
|
||||
private static function rewrite(string $address, string $key, string $target): string
|
||||
{
|
||||
[$local] = self::split($address);
|
||||
if (str_starts_with($target, '@')) {
|
||||
return $local . $target;
|
||||
}
|
||||
$extension = self::extension($local);
|
||||
if ($key === $address || $extension === '') {
|
||||
return $target;
|
||||
}
|
||||
[$targetLocal, $targetDomain] = self::split($target);
|
||||
return ($targetDomain === 'localhost' || self::extension($targetLocal) !== '')
|
||||
? $target
|
||||
: $targetLocal . '+' . $extension . '@' . $targetDomain;
|
||||
}
|
||||
|
||||
/** Comma separated lookup result; null when empty (not found) */
|
||||
private static function targets(?string $value): ?array
|
||||
{
|
||||
$targets = array_values(array_filter(array_map(fn(string $target): string => self::target($target), explode(',', (string)$value))));
|
||||
return empty($targets) ? null : $targets;
|
||||
}
|
||||
|
||||
/** Address or '@domain' from a lookup result, lower case; '' if invalid */
|
||||
private static function target(?string $value): string
|
||||
{
|
||||
$value = strtolower(trim((string)$value));
|
||||
return preg_match('/^[^@\s,<>]*@[^@\s,<>]+$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
/** Lower case address without angle brackets; '' unless local@domain */
|
||||
private static function address(mixed $value): string
|
||||
{
|
||||
$value = is_string($value) ? strtolower(trim($value, " \t\r\n<>")) : '';
|
||||
return preg_match('/^[^@\s,<>]+@[^@\s,<>]+$/', $value) ? $value : '';
|
||||
}
|
||||
|
||||
private static function untagged(string $address): string
|
||||
{
|
||||
[$local, $domain] = self::split($address);
|
||||
$pos = strpos($local, '+');
|
||||
return ($pos === false || $pos === 0) ? $address : substr($local, 0, $pos) . '@' . $domain;
|
||||
}
|
||||
|
||||
/** Address extension after the recipient delimiter "+" */
|
||||
private static function extension(string $local): string
|
||||
{
|
||||
$pos = strpos($local, '+');
|
||||
return ($pos === false || $pos === 0) ? '' : substr($local, $pos + 1);
|
||||
}
|
||||
|
||||
/** [local part, domain] of an address or '@domain' */
|
||||
private static function split(string $address): array
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
return ($at === false) ? [$address, ''] : [substr($address, 0, $at), substr($address, $at + 1)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\LookupRepository;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Smtp\SmtpClient;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\Input;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDOException;
|
||||
|
||||
/**
|
||||
* Admin use cases of the archive target. Read-only API keys may read the settings (never password or key).
|
||||
*/
|
||||
class SettingsService
|
||||
{
|
||||
private const MAX_SECRET_LENGTH = 255;
|
||||
/** Extensions the archive should announce: Postfix passes 8-bit mails unchanged (archive_smtp in master.cf) */
|
||||
private const WANTED_EXTENSIONS = ['8BITMIME'];
|
||||
|
||||
public function __construct(private SettingsRepository $repo, private LookupRepository $lookup, private ArchiveState $state,
|
||||
private SmtpClient $smtp, private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/** @throws AccessDenied */
|
||||
public function get(): Settings
|
||||
{
|
||||
$this->context->requireAdmin(false);
|
||||
return $this->repo->load();
|
||||
}
|
||||
|
||||
/** Global switch for internal callers (page templates, mailbox details), no permission check; off while the table is missing */
|
||||
public function enabled(): bool
|
||||
{
|
||||
try {
|
||||
return $this->repo->load()->enabled;
|
||||
} catch (PDOException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the settings: attributes missing in $input keep their value, an empty password or signing key keeps
|
||||
* the stored one, `password_remove` deletes the password. Switching on needs an address (and a signing key for archivecow).
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function save(array $input): Settings
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
$settings = $this->repo->load();
|
||||
if (array_key_exists('address', $input)) {
|
||||
$address = strtolower((string)Input::text($input['address']));
|
||||
if (!is_scalar($input['address']) || ($address !== '' && !filter_var($address, FILTER_VALIDATE_EMAIL))) {
|
||||
throw new UserError('archive_address_invalid');
|
||||
}
|
||||
// Postfix would treat an own address as local (aliases, unknown users) before the copy reaches the archive
|
||||
if ($address !== '' && $this->lookup->isMailcowDomain(substr($address, strrpos($address, '@') + 1))) {
|
||||
throw new UserError('archive_address_local');
|
||||
}
|
||||
$settings->address = $address;
|
||||
}
|
||||
if (array_key_exists('host', $input)) {
|
||||
$host = strtolower(trim((string)Input::text($input['host']), " []"));
|
||||
if (!is_scalar($input['host']) || ($host !== '' && !filter_var($host, FILTER_VALIDATE_IP)
|
||||
&& !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))) {
|
||||
throw new UserError('archive_host_invalid');
|
||||
}
|
||||
$settings->host = $host;
|
||||
}
|
||||
if (array_key_exists('port', $input)) {
|
||||
$settings->port = Input::intInRange($input['port'], 1, 65535) ?? throw new UserError('archive_port_invalid');
|
||||
}
|
||||
if (array_key_exists('tls', $input)) {
|
||||
$settings->tls = in_array($input['tls'], Settings::TLS_MODES, true) ? $input['tls'] : throw new UserError('archive_tls_invalid');
|
||||
}
|
||||
if (array_key_exists('username', $input)) {
|
||||
$username = Input::text($input['username']);
|
||||
if ($username === null || strlen($username) > self::MAX_SECRET_LENGTH || preg_match('/[\s:]/', $username)) {
|
||||
throw new UserError('archive_username_invalid');
|
||||
}
|
||||
$settings->username = $username;
|
||||
}
|
||||
if (array_key_exists('password', $input)) {
|
||||
$settings->password = self::secret($input['password'], $settings->password, 'archive_password_invalid');
|
||||
}
|
||||
if (self::flag($input['password_remove'] ?? '0')) {
|
||||
$settings->password = '';
|
||||
}
|
||||
if (array_key_exists('profile', $input)) {
|
||||
$settings->profile = in_array($input['profile'], Settings::PROFILES, true) ? $input['profile'] : throw new UserError('archive_profile_invalid');
|
||||
}
|
||||
if (array_key_exists('signing_secret', $input)) {
|
||||
$settings->signingKey = self::secret($input['signing_secret'], $settings->signingKey, 'archive_signing_secret_invalid');
|
||||
}
|
||||
foreach (['header_from' => 'headerFrom', 'header_rcpt' => 'headerRcpt'] as $key => $property) {
|
||||
if (array_key_exists($key, $input)) {
|
||||
$name = Input::text($input[$key]);
|
||||
if ($name === null || !preg_match('/^[A-Za-z][A-Za-z0-9-]{0,63}$/', $name)) {
|
||||
throw new UserError('archive_header_invalid');
|
||||
}
|
||||
$settings->$property = $name;
|
||||
}
|
||||
}
|
||||
if (array_key_exists('enabled', $input)) {
|
||||
$settings->enabled = self::flag($input['enabled']);
|
||||
}
|
||||
if ($settings->enabled && !$settings->canDeliver()) {
|
||||
throw new UserError('archive_incomplete');
|
||||
}
|
||||
$this->repo->save($settings);
|
||||
$this->state->setEnabled($settings->enabled);
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tests the stored target: connection, TLS, AUTH and whether the archive accepts the address (no mail is sent).
|
||||
* Returns the success message, with the missing extension when the archive lacks 8BITMIME.
|
||||
*
|
||||
* @throws UserError
|
||||
*/
|
||||
public function test(): string|array
|
||||
{
|
||||
$this->context->requireAdmin();
|
||||
$settings = $this->repo->load();
|
||||
if ($settings->address === '') {
|
||||
throw new UserError('archive_incomplete');
|
||||
}
|
||||
$host = ($settings->host !== '') ? $settings->host : $this->smtp->mailHost(substr($settings->address, strrpos($settings->address, '@') + 1));
|
||||
try {
|
||||
$extensions = $this->smtp->probe($host, $settings->port, $settings->tls, $settings->username, $settings->password,
|
||||
Settings::SENDER, $settings->address);
|
||||
} catch (SmtpException $e) {
|
||||
throw new UserError('archive_test_failed', [htmlspecialchars($host . ':' . $settings->port . ' ' . $e->describe())]);
|
||||
}
|
||||
$missing = array_values(array_diff(self::WANTED_EXTENSIONS, $extensions));
|
||||
return empty($missing) ? 'archive_test_ok' : ['archive_test_ok_missing', implode(', ', $missing)];
|
||||
}
|
||||
|
||||
/** A new secret, or the stored one when $value is empty */
|
||||
private static function secret(mixed $value, string $current, string $error): string
|
||||
{
|
||||
$secret = Input::text($value);
|
||||
if ($secret === null || strlen($secret) > self::MAX_SECRET_LENGTH || preg_match('/[^\x21-\x7e]/', $secret)) {
|
||||
throw new UserError($error);
|
||||
}
|
||||
return ($secret === '') ? $current : $secret;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Legacy\LegacyQueue;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
|
||||
/**
|
||||
* State of the archive for the admin: the summary of the archive tab (copies waiting in the Postfix queue with the
|
||||
* reason Postfix gives, last delivery, mails not archived) and the archive log of the dashboard.
|
||||
*/
|
||||
class StatusService
|
||||
{
|
||||
/** Mails not archived count as a problem for a day */
|
||||
private const DROPPED_WINDOW = 86400;
|
||||
|
||||
public function __construct(private SettingsRepository $settings, private ArchiveState $state, private LegacyQueue $queue,
|
||||
private Context $context)
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* GET archive/status (admins, read-only keys included).
|
||||
*
|
||||
* @throws AccessDenied
|
||||
*/
|
||||
public function status(): array
|
||||
{
|
||||
$this->context->requireAdmin(false);
|
||||
$settings = $this->settings->load();
|
||||
$copies = $this->queue->entries(Settings::SENDER);
|
||||
$reasons = [];
|
||||
$oldest = null;
|
||||
$bytes = 0;
|
||||
foreach ($copies as $copy) {
|
||||
$arrival = (int)($copy['arrival_time'] ?? 0);
|
||||
$oldest = ($oldest === null) ? $arrival : min($oldest, $arrival);
|
||||
$bytes += (int)($copy['message_size'] ?? 0);
|
||||
foreach ((array)($copy['recipients'] ?? []) as $rcpt) {
|
||||
if (is_string($rcpt) && preg_match('/^\S+ \((.+)\)$/', $rcpt, $m)) {
|
||||
$reasons[$m[1]] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$dropped = $this->state->droppedStats();
|
||||
$recent = array_values(array_filter($dropped['recent'], fn(array $d): bool => $d['time'] >= time() - self::DROPPED_WINDOW));
|
||||
$lastDelivery = null;
|
||||
foreach ($this->state->events() as $event) {
|
||||
if ($event['status'] === 'sent') {
|
||||
$lastDelivery = $event['time'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
return [
|
||||
'enabled' => (int)$settings->enabled,
|
||||
'configured' => (int)$settings->canDeliver(),
|
||||
'queued' => count($copies),
|
||||
'queued_bytes' => $bytes,
|
||||
'oldest_queued' => $oldest,
|
||||
'delay_reasons' => array_slice(array_keys($reasons), 0, 5),
|
||||
'last_delivery' => $lastDelivery,
|
||||
'dropped' => $dropped['count'],
|
||||
'dropped_24h' => count($recent),
|
||||
'last_dropped' => $recent ? ['time' => $recent[0]['time'], 'reason' => $recent[0]['reason']] : null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* GET logs/archive: deliveries, expired copies and mails not archived, newest first (admins, read-only keys included);
|
||||
* $count limits the entries, 0 keeps the default.
|
||||
*
|
||||
* @throws AccessDenied
|
||||
*/
|
||||
public function log(int $count = 0): array
|
||||
{
|
||||
$this->context->requireAdmin(false);
|
||||
return ($count > 0) ? $this->state->events($count) : $this->state->events();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Smtp;
|
||||
|
||||
/**
|
||||
* Small SMTP client: hands archive copies to Postfix (port 591: no milter, mynetworks only) and tests the
|
||||
* connection to the archive server (EHLO, STARTTLS, AUTH, MAIL/RCPT, no mail is sent).
|
||||
*
|
||||
* The socket I/O sits in protected methods, so tests can replace it.
|
||||
*/
|
||||
class SmtpClient
|
||||
{
|
||||
private const TIMEOUT = 20;
|
||||
|
||||
/** @var resource|null */
|
||||
private $socket = null;
|
||||
|
||||
/**
|
||||
* Sends one mail and returns the queue id Postfix reports. $data must end with CRLF; it is received as it is.
|
||||
*
|
||||
* @throws SmtpException
|
||||
*/
|
||||
public function submit(string $host, int $port, string $from, string $rcpt, string $data): string
|
||||
{
|
||||
$this->open($host, $port);
|
||||
try {
|
||||
$this->expect('connect', 220);
|
||||
$this->ehlo();
|
||||
$this->command('sender', 'MAIL FROM:<' . $from . '>', 250);
|
||||
$this->command('recipient', 'RCPT TO:<' . $rcpt . '>', 250);
|
||||
$this->command('data', 'DATA', 354);
|
||||
$this->write(self::dotStuff(self::crlf($data)) . ".\r\n");
|
||||
$reply = $this->expect('data', 250);
|
||||
$this->write("QUIT\r\n");
|
||||
return preg_match('/queued as ([0-9A-Za-z]+)/', $reply, $m) ? $m[1] : '';
|
||||
} finally {
|
||||
$this->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection test against the archive server: STARTTLS (unless $tls is 'may' and it is not offered), AUTH when
|
||||
* a username is set, then MAIL and RCPT for the archive address, then RSET.
|
||||
*
|
||||
* @return string[] extensions the server announced (after STARTTLS)
|
||||
* @throws SmtpException
|
||||
*/
|
||||
public function probe(string $host, int $port, string $tls, string $username, string $password, string $from, string $rcpt): array
|
||||
{
|
||||
$this->open($host, $port);
|
||||
try {
|
||||
$this->expect('connect', 220);
|
||||
$extensions = $this->ehlo();
|
||||
if ($tls !== 'may' || in_array('STARTTLS', $extensions, true)) {
|
||||
if (!in_array('STARTTLS', $extensions, true)) {
|
||||
throw new SmtpException('tls', 'STARTTLS not offered');
|
||||
}
|
||||
$this->command('tls', 'STARTTLS', 220);
|
||||
$this->startTls($host, $tls === 'verify');
|
||||
$extensions = $this->ehlo();
|
||||
}
|
||||
if ($username !== '') {
|
||||
$this->command('auth', 'AUTH PLAIN ' . base64_encode("\0" . $username . "\0" . $password), 235);
|
||||
}
|
||||
$this->command('sender', 'MAIL FROM:<' . $from . '>', 250);
|
||||
$this->command('recipient', 'RCPT TO:<' . $rcpt . '>', 250);
|
||||
$this->command('recipient', 'RSET', 250);
|
||||
$this->write("QUIT\r\n");
|
||||
return $extensions;
|
||||
} finally {
|
||||
$this->close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Mail server of a domain: the MX with the lowest preference, else the domain itself */
|
||||
public function mailHost(string $domain): string
|
||||
{
|
||||
if (@getmxrr($domain, $hosts, $weights) && !empty($hosts)) {
|
||||
array_multisort($weights, SORT_NUMERIC, $hosts);
|
||||
return rtrim($hosts[0], '.');
|
||||
}
|
||||
return $domain;
|
||||
}
|
||||
|
||||
/** CRLF line ends, ending with CRLF */
|
||||
public static function crlf(string $data): string
|
||||
{
|
||||
$data = (string)preg_replace('/\r\n|\r|\n/', "\r\n", $data);
|
||||
return str_ends_with($data, "\r\n") ? $data : $data . "\r\n";
|
||||
}
|
||||
|
||||
/** RFC 5321 4.5.2: a line starting with "." gets another "." */
|
||||
public static function dotStuff(string $data): string
|
||||
{
|
||||
$data = str_replace("\r\n.", "\r\n..", $data);
|
||||
return str_starts_with($data, '.') ? '.' . $data : $data;
|
||||
}
|
||||
|
||||
/** @throws SmtpException */
|
||||
protected function open(string $host, int $port): void
|
||||
{
|
||||
$target = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $host . ']' : $host;
|
||||
$socket = @stream_socket_client('tcp://' . $target . ':' . $port, $errno, $error, self::TIMEOUT);
|
||||
if ($socket === false) {
|
||||
throw new SmtpException('connect', $error !== '' ? $error : 'connection failed');
|
||||
}
|
||||
stream_set_timeout($socket, self::TIMEOUT);
|
||||
$this->socket = $socket;
|
||||
}
|
||||
|
||||
protected function close(): void
|
||||
{
|
||||
if (is_resource($this->socket)) {
|
||||
fclose($this->socket);
|
||||
}
|
||||
$this->socket = null;
|
||||
}
|
||||
|
||||
/** @throws SmtpException */
|
||||
protected function write(string $data): void
|
||||
{
|
||||
for ($written = 0; $written < strlen($data); $written += $count) {
|
||||
$count = @fwrite($this->socket, substr($data, $written, 1048576));
|
||||
if ($count === false || $count === 0) {
|
||||
throw new SmtpException('data', 'connection lost while sending');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One reply line without CRLF, null when the connection ended */
|
||||
protected function readLine(): ?string
|
||||
{
|
||||
$line = fgets($this->socket, 4096);
|
||||
return ($line === false) ? null : rtrim($line, "\r\n");
|
||||
}
|
||||
|
||||
/** @throws SmtpException */
|
||||
protected function startTls(string $host, bool $verify): void
|
||||
{
|
||||
stream_context_set_option($this->socket, 'ssl', 'verify_peer', $verify);
|
||||
stream_context_set_option($this->socket, 'ssl', 'verify_peer_name', $verify);
|
||||
stream_context_set_option($this->socket, 'ssl', 'allow_self_signed', !$verify);
|
||||
stream_context_set_option($this->socket, 'ssl', 'peer_name', $host);
|
||||
if (!@stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||
throw new SmtpException('tls', $verify ? 'TLS handshake failed or certificate not valid' : 'TLS handshake failed');
|
||||
}
|
||||
}
|
||||
|
||||
/** @return string[] extensions in upper case */
|
||||
private function ehlo(): array
|
||||
{
|
||||
$reply = $this->command('connect', 'EHLO ' . (gethostname() ?: 'mailcow'), 250);
|
||||
$extensions = [];
|
||||
foreach (array_slice(explode("\n", $reply), 1) as $line) {
|
||||
$extensions[] = strtoupper((string)strtok(substr($line, 4), ' '));
|
||||
}
|
||||
return $extensions;
|
||||
}
|
||||
|
||||
/** @throws SmtpException */
|
||||
private function command(string $step, string $command, int $code): string
|
||||
{
|
||||
$this->write($command . "\r\n");
|
||||
return $this->expect($step, $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a (multi-line) reply and returns it, lines joined by "\n".
|
||||
*
|
||||
* @throws SmtpException when the code differs
|
||||
*/
|
||||
private function expect(string $step, int $code): string
|
||||
{
|
||||
$lines = [];
|
||||
do {
|
||||
$line = $this->readLine();
|
||||
if ($line === null) {
|
||||
throw new SmtpException($step, 'connection closed');
|
||||
}
|
||||
$lines[] = $line;
|
||||
} while (strlen($line) > 3 && $line[3] === '-');
|
||||
$reply = implode("\n", $lines);
|
||||
if ((int)substr($reply, 0, 3) !== $code && !($code === 250 && (int)substr($reply, 0, 3) === 251)) {
|
||||
throw new SmtpException($step, $reply);
|
||||
}
|
||||
return $reply;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Archive\Smtp;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* An SMTP step failed: connect, tls, auth, sender, recipient or data, with the reply of the server.
|
||||
*/
|
||||
class SmtpException extends RuntimeException
|
||||
{
|
||||
private const MAX_TEXT_LENGTH = 200;
|
||||
|
||||
public function __construct(public readonly string $step, public readonly string $reply)
|
||||
{
|
||||
parent::__construct($step . ': ' . $reply);
|
||||
}
|
||||
|
||||
/** Short text for messages: "<step>: <reply>", without angle brackets (the UI escapes message arguments twice) */
|
||||
public function describe(): string
|
||||
{
|
||||
$text = trim((string)preg_replace('/\s+/', ' ', $this->step . ': ' . str_replace(['<', '>'], '', $this->reply)));
|
||||
return mb_substr($text, 0, self::MAX_TEXT_LENGTH);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
<?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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/** functions.mailbox.inc.php: a mailbox or alias was renamed */
|
||||
public function addressRenamed(string $old, string $new): void
|
||||
{
|
||||
$this->policies->addressRenamed($old, $new);
|
||||
}
|
||||
|
||||
/** Answer for the Postfix policy service: DUNNO or the rejection */
|
||||
public function check(string $sender, string $recipient, string $login, int $port, string $helo = ''): string
|
||||
{
|
||||
return $this->engine->check($sender, $recipient, $login, $port, $helo)->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();
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<?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 ?? []));
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\MailPolicy\Model;
|
||||
|
||||
/**
|
||||
* Result of the policy check for one recipient: allowed, refused by the send or receive part of a policy, or refused
|
||||
* because the recipient is written in a form the check cannot follow (routing through another address).
|
||||
*/
|
||||
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',
|
||||
'address' => 'This form of the recipient address is not allowed by the mail policy',
|
||||
];
|
||||
|
||||
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; without $policy the default text is used */
|
||||
public static function deny(string $side, ?Policy $policy = null): self
|
||||
{
|
||||
return new self(false, $side, $policy);
|
||||
}
|
||||
|
||||
/** The recipient routes through another address (user%domain, domain!user, a second @) */
|
||||
public static function routedAddress(): self
|
||||
{
|
||||
return new self(false, 'address');
|
||||
}
|
||||
|
||||
/** 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::defaultText($this->side);
|
||||
}
|
||||
|
||||
public static function defaultText(string $side): string
|
||||
{
|
||||
return self::TEXT[$side] ?? '';
|
||||
}
|
||||
|
||||
/** Answer for the Postfix policy protocol */
|
||||
public function action(): string
|
||||
{
|
||||
return $this->allowed ? 'DUNNO' : '550 5.7.1 ' . $this->text();
|
||||
}
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
<?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')) : [];
|
||||
}
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
<?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, aliases, 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 Postfix delivers itself (as mysql_virtual_domains_maps.cf: active, no backup MX) */
|
||||
public function isLocalDomain(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d1 AND `active` = '1'
|
||||
UNION SELECT 1 FROM `domain` WHERE `domain` = :d2 AND `active` = '1' AND `backupmx` = '0'",
|
||||
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored spelling of a mailbox, alias or temporary alias address. MySQL compares without case and accents, so
|
||||
* Postfix delivers mail for chéf@ to chef@.
|
||||
*/
|
||||
public function storedAddress(string $address): ?string
|
||||
{
|
||||
$stored = $this->query("SELECT `username` FROM `mailbox` WHERE `username` = :a1 UNION SELECT `address` FROM `alias` WHERE `address` = :a2
|
||||
UNION SELECT `address` FROM `spamalias` WHERE `address` = :a3 LIMIT 1", ['a1' => $address, 'a2' => $address, 'a3' => $address])->fetchColumn();
|
||||
return is_string($stored) ? strtolower($stored) : null;
|
||||
}
|
||||
|
||||
/** Stored spelling of a domain or alias domain, see storedAddress() */
|
||||
public function storedDomain(string $domain): ?string
|
||||
{
|
||||
$stored = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :d1 UNION SELECT `alias_domain` FROM `alias_domain` WHERE `alias_domain` = :d2 LIMIT 1",
|
||||
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||
return is_string($stored) ? strtolower($stored) : null;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Member of an active group */
|
||||
public function isGroupMember(int $groupId, string $username): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id`
|
||||
WHERE `gm`.`group_id` = :id AND `gm`.`username` = :username AND `g`.`active` = '1'",
|
||||
['id' => $groupId, 'username' => $username])->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* What Postfix' virtual alias maps find for this key, in their order: an alias (also the own alias of a mailbox
|
||||
* and the alias of a group address) or a temporary alias.
|
||||
*
|
||||
* @return array{kind: string, domain?: string, internal?: bool, owner?: string}|null
|
||||
*/
|
||||
public function aliasTarget(string $address): ?array
|
||||
{
|
||||
$alias = $this->query("SELECT `domain`, `internal` FROM `alias` WHERE `address` = :address AND `active` IN ('1', '2')",
|
||||
['address' => $address])->fetch();
|
||||
if (is_array($alias)) {
|
||||
return ['kind' => 'alias', 'domain' => strtolower((string)$alias['domain']), 'internal' => $alias['internal'] == '1'];
|
||||
}
|
||||
$owner = $this->query("SELECT `goto` FROM `spamalias` WHERE `address` = :address AND (`validity` >= UNIX_TIMESTAMP() OR `permanent` != 0)",
|
||||
['address' => $address])->fetchColumn();
|
||||
return is_string($owner) ? ['kind' => 'temporary', 'owner' => strtolower($owner)] : null;
|
||||
}
|
||||
|
||||
/** An active catch-all alias of the domain has the internal flag (applies to the whole domain) */
|
||||
public function internalCatchAll(string $domain): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` IN ('1', '2')",
|
||||
['address' => '@' . $domain])->fetchColumn();
|
||||
}
|
||||
|
||||
/** An active policy with members restricts sending */
|
||||
public function hasSendRules(): bool
|
||||
{
|
||||
return (bool)$this->query("SELECT 1 FROM `mail_policy` `p` JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||
WHERE `p`.`active` = '1' AND (`p`.`send_mode` != 'all' OR `p`.`send_block` != '[]') LIMIT 1")->fetchColumn();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of; only
|
||||
* policies of its own domain, so a group with members of other domains cannot restrict them
|
||||
*/
|
||||
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`
|
||||
JOIN `mailbox` `m` ON `m`.`username` = :u1 AND `m`.`domain` = `p`.`domain`
|
||||
WHERE `p`.`active` = '1' AND (`pm`.`username` = :u2 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` = :u3 AND `g`.`active` = '1'))
|
||||
ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username, 'u3' => $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());
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
<?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]);
|
||||
}
|
||||
|
||||
/** Address entries of all lists follow a renamed mailbox or alias */
|
||||
public function renameAddress(string $old, string $new): void
|
||||
{
|
||||
$this->transaction(function () use ($old, $new): void {
|
||||
$columns = ['send_allow', 'send_block', 'receive_allow', 'receive_block'];
|
||||
foreach ($this->query("SELECT `id`, `" . implode('`, `', $columns) . "` FROM `mail_policy`")->fetchAll() as $row) {
|
||||
$changed = [];
|
||||
foreach ($columns as $column) {
|
||||
$entries = json_decode((string)$row[$column], true);
|
||||
if (is_array($entries) && in_array($old, $entries, true)) {
|
||||
$changed[$column] = json_encode(array_values(array_unique(array_map(fn(string $entry): string => ($entry === $old) ? $new : $entry, $entries))));
|
||||
}
|
||||
}
|
||||
if ($changed !== []) {
|
||||
$this->updateRow('mail_policy', (int)$row['id'], $changed);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
<?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: what Postfix delivers the recipient to, looked up in the order of its
|
||||
* virtual alias maps (address, address without +tag, mailbox of an alias domain): the policies of the mailbox, of
|
||||
* the group address or of the owner of a temporary alias, and the `internal` flag of the alias (a catch-all alias
|
||||
* with the flag covers its whole domain). 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; block entries also match the envelope sender. External entries are compared with the envelope sender.
|
||||
* Addresses are compared the way Postfix finds them: lower case, without trailing dot, in their stored spelling
|
||||
* (MySQL ignores accents). Recipients routed through localhost (user%domain@localhost, domain!user@localhost,
|
||||
* user@domain@localhost) are refused, Postfix would deliver them to the routed address.
|
||||
*
|
||||
* Dovecot submits Sieve redirects, vacation replies and notifications on port 588 without login, SOGo sends
|
||||
* reminders with a service login: mail with the address of a mailbox counts as sent by that mailbox. Other mail of
|
||||
* these services (bounces redirected with the null sender, Sieve rejects) has no known sender: it is checked like
|
||||
* mail from outside and does not leave the mailcow while any policy restricts sending. Quota warnings pass.
|
||||
*/
|
||||
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;
|
||||
/** Domain of the Dovecot master login of mailcow's services (SOGo reminders) */
|
||||
private const SERVICE_LOGIN_DOMAIN = 'mailcow.local';
|
||||
/** HELO of the quota warning copies of Dovecot (quota_notify.py), configured by admins */
|
||||
private const QUOTA_HELO = 'quotanotification';
|
||||
/** Recipient without domain every server accepts (RFC 5321) */
|
||||
private const POSTMASTER = 'postmaster';
|
||||
/** Postfix delivers these destinations itself (mydestination), user%domain and domain!user route from there */
|
||||
private const LOCAL_DESTINATIONS = ['localhost', 'localhost.localdomain'];
|
||||
|
||||
/** Lookups of the current check */
|
||||
private array $cache = [];
|
||||
|
||||
public function __construct(private LookupRepository $lookup)
|
||||
{
|
||||
}
|
||||
|
||||
/** $login: SASL username, '' if not logged in; $port: smtpd port of the request, 0 if unknown; $helo: HELO name */
|
||||
public function check(string $sender, string $recipient, string $login, int $port = 0, string $helo = ''): Decision
|
||||
{
|
||||
$this->cache = [];
|
||||
$recipient = self::address($recipient);
|
||||
if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '' || $recipient === self::POSTMASTER) {
|
||||
return Decision::allow();
|
||||
}
|
||||
if (self::isRouted($recipient)) {
|
||||
return Decision::routedAddress();
|
||||
}
|
||||
$recipient = $this->stored($recipient);
|
||||
$sender = $this->stored(self::address($sender));
|
||||
$login = self::address($login);
|
||||
$serviceLogin = self::domainOf($login) === self::SERVICE_LOGIN_DOMAIN;
|
||||
$account = null;
|
||||
if ($login !== '' && !$serviceLogin) {
|
||||
$account = $this->cached('mailbox', $login);
|
||||
} elseif ($serviceLogin || $port === self::SERVICE_PORT) {
|
||||
if ($port === self::SERVICE_PORT && !$serviceLogin && strtolower($helo) === self::QUOTA_HELO) {
|
||||
return Decision::allow();
|
||||
}
|
||||
// without a mailbox sender the sending mailbox is unknown
|
||||
$account = $this->senderMailbox($sender);
|
||||
if ($account === null && !$this->cached('isLocalDomain', self::domainOf($recipient)) && $this->lookup->hasSendRules()) {
|
||||
return Decision::deny('send');
|
||||
}
|
||||
}
|
||||
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 $block): bool => $this->senderMatches($entry, $sender, $account, $block))) {
|
||||
return Decision::deny('receive', $policy);
|
||||
}
|
||||
}
|
||||
return Decision::allow();
|
||||
}
|
||||
|
||||
/** Blocked entries first; then the mode decides. $matches($entry, $block) */
|
||||
private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool
|
||||
{
|
||||
foreach ($block as $entry) {
|
||||
if ($matches($entry, true)) {
|
||||
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, false)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @return array{username: string, domain: string}|null mailbox of the sender address */
|
||||
private function senderMailbox(string $sender): ?array
|
||||
{
|
||||
return str_contains($sender, '@') ? $this->cached('mailbox', $this->resolve(self::untagged($sender))) : null;
|
||||
}
|
||||
|
||||
/** @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->senderMailbox($sender);
|
||||
if ($sentAs !== null && $sentAs['username'] !== $account['username']) {
|
||||
$policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username']));
|
||||
}
|
||||
return self::unique($policies);
|
||||
}
|
||||
|
||||
/** @return Policy[] of what Postfix delivers the recipient to, see the class comment */
|
||||
private function receivingPolicies(string $recipient): array
|
||||
{
|
||||
// the address, then without +tag; an alias domain maps to the mailbox in its target domain and Postfix
|
||||
// looks up the result again with the +tag
|
||||
[$address, $target] = $this->aliasTarget([$recipient, self::untagged($recipient)]);
|
||||
$resolved = $this->resolve($recipient);
|
||||
if ($target === null && $resolved !== $recipient) {
|
||||
[$address, $target] = $this->aliasTarget([$resolved, self::untagged($resolved)]);
|
||||
}
|
||||
$address ??= self::untagged($resolved);
|
||||
if (($target['kind'] ?? '') === 'temporary') {
|
||||
$address = $target['owner'];
|
||||
}
|
||||
$policies = ($this->cached('mailbox', $address) !== null) ? $this->lookup->policiesForMailbox($address) : [];
|
||||
$group = $this->lookup->groupByAddress($address);
|
||||
if ($group !== null) {
|
||||
$policies = array_merge($policies, $this->lookup->policiesForGroup($group));
|
||||
}
|
||||
// the `internal` flag of an alias or group: only senders of its domain
|
||||
if (!empty($target['internal'])) {
|
||||
$policies[] = $this->internalRule($target['domain']);
|
||||
}
|
||||
foreach (array_unique([self::domainOf($recipient), $this->resolveDomain(self::domainOf($recipient))]) as $domain) {
|
||||
if ($this->lookup->internalCatchAll($domain)) {
|
||||
$policies[] = $this->internalRule($domain);
|
||||
}
|
||||
}
|
||||
return self::unique($policies);
|
||||
}
|
||||
|
||||
/** @return array{0: ?string, 1: ?array} the first key Postfix' virtual alias maps find, with what they find */
|
||||
private function aliasTarget(array $keys): array
|
||||
{
|
||||
foreach (array_unique($keys) as $key) {
|
||||
$target = $this->lookup->aliasTarget($key);
|
||||
if ($target !== null) {
|
||||
return [$key, $target];
|
||||
}
|
||||
}
|
||||
return [null, null];
|
||||
}
|
||||
|
||||
/** Receive rule of the `internal` flag */
|
||||
private function internalRule(string $domain): Policy
|
||||
{
|
||||
$policy = new Policy();
|
||||
$policy->domain = $this->resolveDomain($domain);
|
||||
$policy->receiveMode = 'internal';
|
||||
return $policy;
|
||||
}
|
||||
|
||||
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->cached('groupAddress', $id) === $address
|
||||
|| ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address));
|
||||
}
|
||||
if (str_contains($entry, '@')) {
|
||||
return $this->resolve($entry) === $address;
|
||||
}
|
||||
return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry);
|
||||
}
|
||||
|
||||
/** $block: a block entry, which also matches the envelope sender (refusing a forged sender is no risk) */
|
||||
private function senderMatches(string $entry, string $sender, ?array $account, bool $block): bool
|
||||
{
|
||||
$address = $this->resolve(self::untagged($sender));
|
||||
// the sender address of a local entry only counts when logged in: Postfix checked that the login may use it
|
||||
$useAddress = $account !== null || $block;
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = (int)substr($entry, 6);
|
||||
return ($account !== null && $this->cached('isGroupMember', $id, $account['username']))
|
||||
|| ($useAddress && ($this->cached('groupAddress', $id) === $address
|
||||
|| ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address))));
|
||||
}
|
||||
if (str_contains($entry, '@')) {
|
||||
return $this->cached('isLocalDomain', self::domainOf($entry))
|
||||
? ($account !== null && $account['username'] === $this->resolve($entry)) || ($useAddress && $address === $this->resolve($entry))
|
||||
: self::untagged($sender) === $entry;
|
||||
}
|
||||
return $this->cached('isLocalDomain', $entry)
|
||||
? ($account !== null && $account['domain'] === $this->resolveDomain($entry))
|
||||
|| ($useAddress && $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry))
|
||||
: self::domainOf($sender) === $entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored spelling of an address of this mailcow: Postfix finds chéf@ as chef@ (MySQL ignores accents). Only
|
||||
* addresses with non-ASCII characters can differ, as they are already in lower case.
|
||||
*/
|
||||
private function stored(string $address): string
|
||||
{
|
||||
if (!preg_match('/[\x80-\xff]/', $address) || !str_contains($address, '@')) {
|
||||
return $address;
|
||||
}
|
||||
foreach (array_unique([$address, self::untagged($address)]) as $key) {
|
||||
$stored = $this->cached('storedAddress', $key);
|
||||
if ($stored !== null) {
|
||||
return $stored;
|
||||
}
|
||||
}
|
||||
$domain = $this->cached('storedDomain', self::domainOf($address));
|
||||
return ($domain === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $domain;
|
||||
}
|
||||
|
||||
/** 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->cached('aliasDomainTarget', $domain) : null;
|
||||
return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target;
|
||||
}
|
||||
|
||||
private function resolveDomain(string $domain): string
|
||||
{
|
||||
return ($domain === '') ? '' : ($this->cached('aliasDomainTarget', $domain) ?? $domain);
|
||||
}
|
||||
|
||||
/** A lookup, done once per check: long lists ask the same domains and groups again */
|
||||
private function cached(string $method, int|string ...$args): mixed
|
||||
{
|
||||
$key = $method . "\0" . implode("\0", $args);
|
||||
if (!array_key_exists($key, $this->cache)) {
|
||||
$this->cache[$key] = $this->lookup->$method(...$args);
|
||||
}
|
||||
return $this->cache[$key];
|
||||
}
|
||||
|
||||
/** @param Policy[] $policies @return Policy[] without repeated policies */
|
||||
private static function unique(array $policies): array
|
||||
{
|
||||
$unique = [];
|
||||
foreach ($policies as $policy) {
|
||||
$unique[$policy->id ?? 'internal:' . $policy->domain] = $policy;
|
||||
}
|
||||
return array_values($unique);
|
||||
}
|
||||
|
||||
/** Lower case, without angle brackets, quotes and trailing dot of the domain, as Postfix resolves it; '' = null sender */
|
||||
private static function address(string $value): string
|
||||
{
|
||||
$value = strtolower(trim($value, " \t\r\n<>"));
|
||||
$at = strrpos($value, '@');
|
||||
return ($at === false) ? $value : str_replace('"', '', substr($value, 0, $at)) . '@' . rtrim(substr($value, $at + 1), '.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Postfix would deliver the recipient elsewhere: no domain, or routing through a local destination
|
||||
* (localhost, the IP of the server) with %, ! or a second @ in the local part
|
||||
*/
|
||||
private static function isRouted(string $address): bool
|
||||
{
|
||||
$at = strrpos($address, '@');
|
||||
if ($at === false || $at === 0 || $at === strlen($address) - 1) {
|
||||
return true;
|
||||
}
|
||||
$domain = substr($address, $at + 1);
|
||||
return (in_array($domain, self::LOCAL_DESTINATIONS, true) || str_starts_with($domain, '['))
|
||||
&& strpbrk(substr($address, 0, $at), '@%!') !== false;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
<?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\Decision;
|
||||
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 of that domain. Groups need the `mailbox_groups`
|
||||
* ACL as well: without it a domain admin sees no groups and keeps the group entries and assignments.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Address entries of the lists follow a renamed mailbox or alias; called after the rename, without permission check */
|
||||
public function addressRenamed(string $old, string $new): void
|
||||
{
|
||||
$this->repo->renameAddress(strtolower($old), strtolower($new));
|
||||
}
|
||||
|
||||
/** @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->canUseGroups()) ? $this->repo->groupsIn($domains) : [];
|
||||
}
|
||||
|
||||
private function canUseGroups(): bool
|
||||
{
|
||||
return $this->context->hasAdminRights() || $this->context->hasAcl('mailbox_groups');
|
||||
}
|
||||
|
||||
/**
|
||||
* "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; a sender of another domain counts as not logged in
|
||||
$ownSender = $this->context->hasAdminRights() || $this->context->canAccessDomain(self::domainOf($sender));
|
||||
if (!$ownSender && !$this->context->canAccessDomain(self::domainOf($recipient))) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
$login = ($loggedIn && $ownSender && $this->repo->mailboxDomain($sender) !== null) ? $sender : '';
|
||||
$decision = $this->engine->check($sender, $recipient, $login);
|
||||
$policy = $decision->policy;
|
||||
$hidden = $policy !== null && $policy->id !== null && !$this->visible($policy);
|
||||
return [
|
||||
'allowed' => (int)$decision->allowed,
|
||||
'side' => $decision->side,
|
||||
'policy' => ($policy !== null && $policy->id !== null && !$hidden) ? $policy->name : null,
|
||||
'internal_alias' => (int)($policy !== null && $policy->id === null),
|
||||
'logged_in' => (int)($login !== ''),
|
||||
// the text of a policy of another domain stays hidden
|
||||
'text' => $decision->allowed ? '' : ($hidden ? Decision::defaultText($decision->side) : $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');
|
||||
}
|
||||
}
|
||||
$groupAccess = $this->canUseGroups();
|
||||
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)) {
|
||||
continue;
|
||||
}
|
||||
$entries = array_key_exists($key, $input) ? self::values($input[$key]) : self::withoutGroups($policy->$property);
|
||||
$kept = [];
|
||||
if (!$groupAccess) {
|
||||
// without access to groups the group entries stay (also when sent back unchanged); the form shows no group choices
|
||||
$kept = array_values(array_diff($policy->$property, self::withoutGroups($policy->$property)));
|
||||
$entries = array_values(array_diff(array_map('strtolower', $entries), $kept));
|
||||
} elseif (array_key_exists($key . '_groups', $input)) {
|
||||
$entries = array_merge(self::withoutGroups($entries), array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups'])));
|
||||
}
|
||||
$entries = array_values(array_unique(array_merge($this->entries($entries, $policy->domain, $groupAccess), $kept)));
|
||||
if (count($entries) > self::MAX_ENTRIES) {
|
||||
throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
|
||||
}
|
||||
$policy->$property = $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::limited($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);
|
||||
}
|
||||
// without access to groups the assignments stay; only sending them back unchanged is accepted
|
||||
if (array_key_exists('groups', $input) && !$groupAccess && self::values($input['groups']) !== []) {
|
||||
$sent = array_map('intval', self::values($input['groups']));
|
||||
$stored = $policy->groups;
|
||||
sort($sent);
|
||||
sort($stored);
|
||||
if (array_values(array_unique($sent)) !== $stored) {
|
||||
throw new AccessDenied();
|
||||
}
|
||||
}
|
||||
if (array_key_exists('groups', $input) && $groupAccess) {
|
||||
$policy->groups = [];
|
||||
foreach (self::limited($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> of a group of the policy's
|
||||
* domain, lower case, without repeats.
|
||||
*
|
||||
* @param string[] $values
|
||||
* @return string[]
|
||||
* @throws UserError
|
||||
*/
|
||||
private function entries(array $values, string $domain, bool $groupAccess): array
|
||||
{
|
||||
$entries = [];
|
||||
foreach (self::limited(array_map('strtolower', $values)) as $entry) {
|
||||
if (str_starts_with($entry, 'group:')) {
|
||||
$id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX);
|
||||
if (!$groupAccess || $id === null || $this->repo->groupDomain($id) !== $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;
|
||||
}
|
||||
return array_values($entries);
|
||||
}
|
||||
|
||||
/**
|
||||
* Values of a list field without repeats, at most MAX_ENTRIES (checked before anything is looked up)
|
||||
*
|
||||
* @return string[]
|
||||
* @throws UserError
|
||||
*/
|
||||
private static function limited(mixed $value): array
|
||||
{
|
||||
$values = array_values(array_unique(self::values($value)));
|
||||
if (count($values) > self::MAX_ENTRIES) {
|
||||
throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
|
||||
}
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @return string[] entries without group:<id> */
|
||||
private static function withoutGroups(array $entries): array
|
||||
{
|
||||
return array_values(array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -205,6 +205,9 @@ $MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update'] = false;
|
||||
// Force 2FA enrollment at next login
|
||||
$MAILBOX_DEFAULT_ATTRIBUTES['force_tfa'] = false;
|
||||
|
||||
// Archive mails of the mailbox (archivecow): "inherit" follows the domain, "1" always, "0" never
|
||||
$MAILBOX_DEFAULT_ATTRIBUTES['archive'] = 'inherit';
|
||||
|
||||
// Enable SOGo access - Users will be redirected to SOGo after login (set to false to disable redirect by default)
|
||||
$MAILBOX_DEFAULT_ATTRIBUTES['sogo_access'] = true;
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
// Archive tab of the admin system page: header profile fields and, while archiving is enabled, the state of the archive
|
||||
// under the switch (one line per problem; the details are in the archive log of the dashboard and in the queue manager).
|
||||
var mailcowArchive = (function($) {
|
||||
'use strict';
|
||||
|
||||
function L(key) {
|
||||
return (lang[key] !== undefined) ? lang[key] : key;
|
||||
}
|
||||
function esc(s) {
|
||||
return escapeHtml(s === null || s === undefined ? '' : String(s));
|
||||
}
|
||||
function time(ts) {
|
||||
return ts ? new Date(ts * 1000).toLocaleString(LOCALE, DATETIME_FORMAT) : '-';
|
||||
}
|
||||
// lang string with %s placeholders, values escaped
|
||||
function text(key, values) {
|
||||
var i = 0;
|
||||
return esc(L(key)).replace(/%s/g, function() { return esc(values[i++]); });
|
||||
}
|
||||
function notice(cls, html) {
|
||||
return '<div class="alert alert-' + cls + ' py-2 mb-2">' + html + '</div>';
|
||||
}
|
||||
var loaded = 0;
|
||||
|
||||
function renderStatus(s) {
|
||||
var html = '';
|
||||
if (s.enabled && !s.configured) {
|
||||
html += notice('warning', esc(L('archive_state_unconfigured')));
|
||||
}
|
||||
if (s.dropped_24h) {
|
||||
html += notice('danger', text('archive_state_dropped', [s.dropped_24h, time(s.last_dropped.time), s.last_dropped.reason]));
|
||||
}
|
||||
if (s.queued) {
|
||||
var reasons = $.map(s.delay_reasons || [], function(r) { return '<li class="text-break">' + esc(r) + '</li>'; });
|
||||
html += notice('warning', text('archive_state_queued', [s.queued, time(s.oldest_queued)]) +
|
||||
(reasons.length ? '<ul class="mb-0 mt-1 small">' + reasons.join('') + '</ul>' : ''));
|
||||
}
|
||||
if (!s.enabled) {
|
||||
html += notice('secondary', esc(L('archive_state_off')));
|
||||
} else if (!html) {
|
||||
html += notice('success', s.last_delivery ? text('archive_state_ok', [time(s.last_delivery)]) : esc(L('archive_state_ok_none')));
|
||||
}
|
||||
$('#archive_state').html(html);
|
||||
}
|
||||
|
||||
function load() {
|
||||
loaded = Date.now();
|
||||
$.get('/api/v1/get/archive/status', function(s) { renderStatus(s || {}); }, 'json').fail(function() {
|
||||
$('#archive_state').html(notice('danger', esc(lang_danger.generic_server_error)));
|
||||
});
|
||||
}
|
||||
|
||||
// fields of the selected header profile only; hidden fields are not posted, so their stored values are kept
|
||||
function showProfile() {
|
||||
var profile = $('#archive_profile').val();
|
||||
$('.archive-profile').each(function() {
|
||||
var on = $(this).hasClass('archive-profile-' + profile);
|
||||
$(this).toggleClass('d-none', !on).find(':input').prop('disabled', !on);
|
||||
});
|
||||
$('.archive-profile-info').each(function() {
|
||||
$(this).toggleClass('d-none', !$(this).hasClass('archive-profile-info-' + profile));
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
$('#archive_profile').on('change', showProfile);
|
||||
// the tests use the saved settings: not with unsaved changes in the form
|
||||
$('form[data-id="archive"]').on('input change', ':input', function() {
|
||||
$('.archive-test').prop('disabled', true);
|
||||
$('#archive_test_info').text(L('archive_save_first')).removeClass('text-muted').addClass('text-warning');
|
||||
});
|
||||
// the state line is only shown while archiving is enabled
|
||||
var tab = document.getElementById('collapse-tab-archive');
|
||||
if (!tab || !document.getElementById('archive_state')) return;
|
||||
// the tab body is at the top: visible as soon as the tab (or on mobile its card) is opened; load again when shown later
|
||||
new IntersectionObserver(function(entries) {
|
||||
if (entries.some(function(e) { return e.intersectionRatio > 0; }) && Date.now() - loaded > 10000) load();
|
||||
}).observe(tab);
|
||||
$('#archive_refresh').on('click', function(e) {
|
||||
e.preventDefault();
|
||||
load();
|
||||
});
|
||||
// open the dashboard with the archive log (the dashboard restores the last tab)
|
||||
$('#archive_log_link').on('click', function() {
|
||||
try {
|
||||
localStorage.setItem('lastTag', 'tab-archive-logs');
|
||||
} catch (e) {}
|
||||
});
|
||||
});
|
||||
|
||||
return {load: load};
|
||||
})(jQuery);
|
||||
@@ -875,6 +875,81 @@ jQuery(function($){
|
||||
hideTableExpandCollapseBtn('#tab-cron-logs', '#cron_log');
|
||||
});
|
||||
}
|
||||
// archive copies from the Postfix log and mails not archived; the Message-ID finds a mail
|
||||
function draw_archive_logs() {
|
||||
// just recalc width if instance already exists
|
||||
if ($.fn.DataTable.isDataTable('#archive_log') ) {
|
||||
$('#archive_log').DataTable().columns.adjust().responsive.recalc();
|
||||
return;
|
||||
}
|
||||
// softbounce: the archive refused the copy, Postfix keeps it and tries again
|
||||
var status_class = {sent: 'bg-success', deferred: 'bg-warning text-dark', softbounce: 'bg-danger', bounced: 'bg-danger', expired: 'bg-danger', dropped: 'bg-danger'};
|
||||
|
||||
$('#archive_log').DataTable({
|
||||
responsive: true,
|
||||
processing: true,
|
||||
serverSide: false,
|
||||
stateSave: true,
|
||||
pageLength: log_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: [[0, 'desc']],
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/logs/archive",
|
||||
dataSrc: function(data){
|
||||
return Array.isArray(data) ? data : [];
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
title: lang.time,
|
||||
data: 'time',
|
||||
defaultContent: '',
|
||||
createdCell: function(td, cellData) {
|
||||
createSortableDate(td, cellData)
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.archive_delivery,
|
||||
data: 'status',
|
||||
defaultContent: '',
|
||||
render: function(data) {
|
||||
var label = lang['archive_delivery_' + data] || data;
|
||||
return '<span class="badge ' + (status_class[data] || 'bg-secondary') + '">' + escapeHtml(label) + '</span>';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.archive_message_id,
|
||||
data: 'message_id',
|
||||
defaultContent: '',
|
||||
className: 'text-break',
|
||||
render: function(data) {
|
||||
return escapeHtml(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.archive_queue_id,
|
||||
data: 'qid',
|
||||
defaultContent: '',
|
||||
render: function(data) {
|
||||
return escapeHtml(data);
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.archive_details,
|
||||
data: 'text',
|
||||
defaultContent: '',
|
||||
className: 'dtr-col-md text-break',
|
||||
render: function(data) {
|
||||
return escapeHtml(data);
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
function rspamd_pie_graph() {
|
||||
$.ajax({
|
||||
url: '/api/v1/get/rspamd/actions',
|
||||
@@ -1295,6 +1370,7 @@ jQuery(function($){
|
||||
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
|
||||
onVisible("[id^=netfilter_log]", () => draw_netfilter_logs());
|
||||
onVisible("[id^=cron_log]", () => draw_cron_logs());
|
||||
onVisible("[id^=archive_log]", () => draw_archive_logs());
|
||||
onVisible("[id^=rspamd_history]", () => draw_rspamd_history());
|
||||
onVisible("[id^=rspamd_donut]", () => rspamd_pie_graph());
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
// 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.side === 'address' ? L('test_denied_address') :
|
||||
(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);
|
||||
@@ -232,6 +232,7 @@ $(document).ready(function() {
|
||||
} else {
|
||||
$('#addDomain_gal').prop('checked', false);
|
||||
}
|
||||
$('#addDomain_archive').prop('checked', template.archive == 1);
|
||||
|
||||
if (template.active == 1){
|
||||
$('#addDomain_active').prop('checked', true);
|
||||
@@ -433,6 +434,7 @@ $(document).ready(function() {
|
||||
} else {
|
||||
$('#force_tfa').prop('checked', false);
|
||||
}
|
||||
$('#addMailbox_archive').selectpicker('val', ['0', '1'].includes(String(template.archive)) ? String(template.archive) : 'inherit');
|
||||
if (template.sogo_access == 1){
|
||||
$('#sogo_access').prop('checked', true);
|
||||
} else {
|
||||
@@ -1187,6 +1189,18 @@ jQuery(function($){
|
||||
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':(0==data?'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>':2==data&&'—');
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.archive,
|
||||
data: 'archive',
|
||||
searchable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 5,
|
||||
// not shown while archiving is switched off
|
||||
className: archive_enabled ? '' : 'never',
|
||||
render: function (data, type) {
|
||||
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>';
|
||||
}
|
||||
},
|
||||
{
|
||||
title: lang.action,
|
||||
data: 'action',
|
||||
@@ -2493,7 +2507,6 @@ 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());
|
||||
|
||||
+19
-15
@@ -280,9 +280,6 @@ 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":
|
||||
@@ -861,19 +858,22 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
break;
|
||||
|
||||
case "mail-policy":
|
||||
case "archive":
|
||||
switch ($object) {
|
||||
case "all":
|
||||
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicies($extra));
|
||||
case "settings":
|
||||
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getSettings());
|
||||
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'));
|
||||
case "status":
|
||||
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getStatus());
|
||||
break;
|
||||
|
||||
case "mailboxes":
|
||||
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getMailboxes());
|
||||
break;
|
||||
|
||||
default:
|
||||
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicy($object));
|
||||
process_get_return(null);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
@@ -924,6 +924,11 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
||||
break;
|
||||
case "archive":
|
||||
// deliveries of archive copies and mails not archived, newest first
|
||||
$logs = \Mailcow\Archive\ArchiveModule::fromGlobals()->api->getLog(isset($extra) ? intval(preg_replace('/[^\d]/', '', $extra)) : 0);
|
||||
echo (!empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
||||
break;
|
||||
case "postfix":
|
||||
// 0 is first record, so empty is fine
|
||||
if (isset($extra)) {
|
||||
@@ -1702,6 +1707,8 @@ if (isset($_GET['query'])) {
|
||||
['db' => 'messages', 'dt' => 20, 'dummy' => true, 'order_subquery' => "SELECT SUM(messages) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`"],
|
||||
['db' => 'tags', 'dt' => 23, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_mailbox` AS `tm` ON `tm`.`username` = `m`.`username`', 'where_column' => '`tm`.`tag_name`']],
|
||||
['db' => 'active', 'dt' => 24],
|
||||
// archived by the mailbox setting or ('inherit') the domain default
|
||||
['db' => 'archive', 'dt' => 25, 'dummy' => true, 'order_subquery' => "SELECT IF(JSON_VALUE(`m`.`attributes`, '$.archive') = '1' OR (COALESCE(JSON_VALUE(`m`.`attributes`, '$.archive'), '') NOT IN ('0', '1') AND `domain`.`archive` = '1'), 1, 0) FROM `domain` WHERE `domain`.`domain` = `m`.`domain`"],
|
||||
];
|
||||
|
||||
if($_SESSION['mailcow_cc_role'] === 'admin') {
|
||||
@@ -1799,9 +1806,6 @@ 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;
|
||||
@@ -1976,8 +1980,8 @@ 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));
|
||||
case "archive":
|
||||
process_edit_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->edit($attr));
|
||||
break;
|
||||
case "recipient_map":
|
||||
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"alias_domains": "Alias-Domains hinzufügen",
|
||||
"alias_external_goto": "Aliase mit externen Ziel-Domains erlauben",
|
||||
"app_passwds": "App-Passwörter verwalten",
|
||||
"archive": "Archivierung von Domains und Mailboxen ändern",
|
||||
"bcc_maps": "BCC-Maps",
|
||||
"delimiter_action": "Delimiter-Aktionen (tags)",
|
||||
"domain_desc": "Domainbeschreibung ändern",
|
||||
@@ -11,7 +12,6 @@
|
||||
"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",
|
||||
@@ -67,7 +67,7 @@
|
||||
"hostname": "Host",
|
||||
"inactive": "Inaktiv",
|
||||
"internal": "Intern",
|
||||
"internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).",
|
||||
"internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.",
|
||||
"sender_allowed": "Als dieser Alias senden erlauben",
|
||||
"kind": "Art",
|
||||
"mailbox_quota_def": "Standard-Quota einer Mailbox",
|
||||
@@ -139,6 +139,57 @@
|
||||
"app_links": "App-Links",
|
||||
"app_name": "App-Name",
|
||||
"apps_name": "\"mailcow Apps\" Name",
|
||||
"archive": "Archivierung",
|
||||
"archive_address": "Archivadresse",
|
||||
"archive_address_info": "mailcow sendet eine Kopie jeder archivierten E-Mail an diese Adresse.",
|
||||
"archive_delivery": "Ergebnis",
|
||||
"archive_delivery_bounced": "Abgewiesen",
|
||||
"archive_delivery_deferred": "Verzögert",
|
||||
"archive_delivery_dropped": "Nicht archiviert",
|
||||
"archive_delivery_expired": "Abgelaufen, verworfen",
|
||||
"archive_delivery_sent": "Zugestellt",
|
||||
"archive_delivery_softbounce": "Abgelehnt, wird wiederholt",
|
||||
"archive_details": "Antwort des Archivs / Grund",
|
||||
"archive_enabled": "Archivierung aktivieren",
|
||||
"archive_enabled_info": "Archiviert die Domains und Mailboxen, bei denen die Archivierung aktiviert ist. Nach dem Deaktivieren entstehen keine neuen Kopien; Kopien in der Mailqueue werden noch zugestellt.",
|
||||
"archive_header_from": "Header-Name für den Envelope-Absender",
|
||||
"archive_header_rcpt": "Header-Name für die Envelope-Empfänger",
|
||||
"archive_host": "Archivserver",
|
||||
"archive_host_info": "Leer lassen, um den MX der Domain der Archivadresse zu verwenden.",
|
||||
"archive_info": "mailcow sendet eine Kopie jeder angenommenen eingehenden, ausgehenden und internen E-Mail archivierter Mailboxen per SMTP an ein externes Archiv (Journaling). Die Kopien laufen über die Mailqueue und werden erneut zugestellt, solange das Archiv nicht erreichbar ist; Absender erhalten dafür nie einen Bounce oder eine Verzögerungswarnung.",
|
||||
"archive_log": "Archiv-Protokoll",
|
||||
"archive_log_info": "Kopien an das Archiv aus dem Postfix-Protokoll im Speicher und E-Mails, die nicht archiviert wurden. Suchen Sie nach der Message-ID einer E-Mail.",
|
||||
"archive_message_id": "Message-ID",
|
||||
"archive_password": "SMTP-Passwort",
|
||||
"archive_password_remove": "Gespeichertes Passwort entfernen",
|
||||
"archive_port": "Port",
|
||||
"archive_profile": "Profil",
|
||||
"archive_profile_archivecow": "archivecow (Besitzer, Envelope, Signatur)",
|
||||
"archive_profile_archivecow_info": "Für archivecow: Signierte Header über der E-Mail nennen die archivierten Mailboxen, denen sie gehört, und den SMTP-Envelope (auch BCC-Empfänger). archivecow prüft die Signatur mit dem Signaturschlüssel.",
|
||||
"archive_profile_envelope": "Envelope-Absender und -Empfänger",
|
||||
"archive_profile_envelope_info": "Für andere Archive (z. B. MailPiler): Zwei Header über der E-Mail nennen Envelope-Absender und -Empfänger, damit das Archiv auch BCC-Empfänger zuordnen kann. Die Header sind nicht signiert.",
|
||||
"archive_profile_none": "Keine (unveränderte Kopie)",
|
||||
"archive_profile_none_info": "Die E-Mail geht unverändert ins Archiv. BCC-Empfänger sind nicht erkennbar, das Archiv ordnet die E-Mail nur nach ihren eigenen Headern (From, To, Cc) zu.",
|
||||
"archive_queue_id": "Queue-ID",
|
||||
"archive_save_first": "Erst speichern: Die Tests verwenden die gespeicherten Einstellungen.",
|
||||
"archive_section_connection": "Verbindung",
|
||||
"archive_section_headers": "Archiv-Header",
|
||||
"archive_signing_secret": "Signaturschlüssel",
|
||||
"archive_signing_secret_info": "Signiert die Archiv-Header (X-Archive-Signature). Muss mit dem Schlüssel in archivecow übereinstimmen.",
|
||||
"archive_state_dropped": "%s E-Mails wurden in den letzten 24 Stunden nicht archiviert, zuletzt um %s: %s",
|
||||
"archive_state_off": "Die Archivierung ist deaktiviert.",
|
||||
"archive_state_ok": "Die Archivierung läuft, letzte Zustellung an das Archiv: %s",
|
||||
"archive_state_ok_none": "Die Archivierung ist aktiv, das Protokoll enthält noch keine Zustellung an das Archiv",
|
||||
"archive_state_queued": "%s Kopien warten auf das Archiv, die älteste seit %s",
|
||||
"archive_state_unconfigured": "Archivadresse oder Signaturschlüssel fehlt",
|
||||
"archive_test": "Verbindung testen",
|
||||
"archive_test_info": "Die Tests verwenden die gespeicherten Einstellungen. Die Test-E-Mail geht über die Mailqueue an das Archiv.",
|
||||
"archive_testmail": "Test-E-Mail senden",
|
||||
"archive_tls": "TLS",
|
||||
"archive_tls_encrypt": "Erforderlich, ohne Zertifikatsprüfung",
|
||||
"archive_tls_may": "Wenn verfügbar",
|
||||
"archive_tls_verify": "Erforderlich, mit Zertifikatsprüfung",
|
||||
"archive_username": "SMTP-Benutzername",
|
||||
"arrival_time": "Ankunftszeit (Serverzeit)",
|
||||
"authed_user": "Auth. Benutzer",
|
||||
"ays": "Soll der Vorgang wirklich ausgeführt werden?",
|
||||
@@ -517,6 +568,19 @@
|
||||
"api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden",
|
||||
"app_name_empty": "App-Name darf nicht leer sein",
|
||||
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
|
||||
"archive_address_invalid": "Ungültige Archivadresse",
|
||||
"archive_address_local": "Die Archivadresse darf in keiner Domain oder Alias-Domain dieser mailcow liegen",
|
||||
"archive_header_invalid": "Ungültiger Header-Name: Buchstaben, Ziffern und Bindestriche, beginnend mit einem Buchstaben",
|
||||
"archive_host_invalid": "Ungültiger Archivserver",
|
||||
"archive_incomplete": "Für die Archivierung sind eine Archivadresse und beim Profil archivecow ein Signaturschlüssel erforderlich",
|
||||
"archive_password_invalid": "Ungültiges SMTP-Passwort: druckbare ASCII-Zeichen ohne Leerzeichen, höchstens 255 Zeichen",
|
||||
"archive_port_invalid": "Ungültiger Port",
|
||||
"archive_profile_invalid": "Ungültiges Profil für die Archiv-Header",
|
||||
"archive_signing_secret_invalid": "Ungültiger Signaturschlüssel: druckbare ASCII-Zeichen ohne Leerzeichen, höchstens 255 Zeichen",
|
||||
"archive_test_failed": "Verbindungstest zum Archiv fehlgeschlagen: %s",
|
||||
"archive_testmail_failed": "Test-E-Mail konnte nicht in die Queue gestellt werden: %s",
|
||||
"archive_tls_invalid": "Ungültiger TLS-Modus",
|
||||
"archive_username_invalid": "Ungültiger SMTP-Benutzername",
|
||||
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
|
||||
"bcc_empty": "BCC-Ziel darf nicht leer sein",
|
||||
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
|
||||
@@ -583,14 +647,6 @@
|
||||
"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": "Höchstens %s Einträge pro Liste oder Auswahl möglich",
|
||||
"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)",
|
||||
@@ -789,6 +845,15 @@
|
||||
"app_name": "App-Name",
|
||||
"app_passwd": "App-Passwörter",
|
||||
"app_passwd_protocols": "Zugelassene Protokolle für App-Passwort",
|
||||
"archive": "Archivierung",
|
||||
"archive_active": "Archiviert",
|
||||
"archive_domain": "Alle Mailboxen standardmäßig archivieren",
|
||||
"archive_domain_info": "Gilt für Mailboxen mit der Einstellung „Domain-Standard“.",
|
||||
"archive_inactive": "Nicht archiviert",
|
||||
"archive_info": "„Domain-Standard“ folgt der Einstellung der Domain. Von archivierten Mailboxen geht eine Kopie jeder gesendeten und empfangenen E-Mail an das Archiv.",
|
||||
"archive_inherit": "Domain-Standard",
|
||||
"archive_off": "Deaktiviert",
|
||||
"archive_on": "Aktiviert",
|
||||
"backup_mx_options": "Relay-Optionen",
|
||||
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
|
||||
"client_id": "Client-ID",
|
||||
@@ -838,7 +903,7 @@
|
||||
"iam_unlink": "Verknüpfung lösen",
|
||||
"inactive": "Inaktiv",
|
||||
"internal": "Intern",
|
||||
"internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).",
|
||||
"internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.",
|
||||
"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",
|
||||
@@ -978,7 +1043,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 E-Mails von angemeldeten Absendern der Domain annehmen",
|
||||
"internal": "Nur Mails von Absendern der Domain annehmen",
|
||||
"list_error": "Gruppen konnten nicht geladen werden",
|
||||
"managed_alias": "Wird von Gruppe %s verwaltet",
|
||||
"members": "Mitglieder",
|
||||
@@ -1045,61 +1110,6 @@
|
||||
"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 aus der Domain der Richtlinie, 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_receive": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain. Erlaubte Adressen und Domains dieser mailcow gelten nur für angemeldete Absender, gesperrte auch für die Absenderadresse im Umschlag. Externe Absender werden an der Absenderadresse im Umschlag erkannt.",
|
||||
"entries_info_send": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain.",
|
||||
"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_address": "Abgelehnt: Diese Schreibweise leitet über eine andere Adresse um und ist nicht erlaubt.",
|
||||
"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 Ihrer Domains und wurde als nicht angemeldet geprüft.",
|
||||
"test_recipient": "Empfänger",
|
||||
"test_run": "Testen",
|
||||
"test_sender": "Absender",
|
||||
"view": "Ansehen"
|
||||
},
|
||||
"mailbox": {
|
||||
"action": "Aktion",
|
||||
"activate": "Aktivieren",
|
||||
@@ -1125,6 +1135,7 @@
|
||||
"allow_from_smtp": "Nur folgende IPs für <b>SMTP</b> erlauben",
|
||||
"allow_from_smtp_info": "Leer lassen, um keine Prüfung durchzuführen.<br>IPv4- sowie IPv6-Adressen und Subnetzwerke.",
|
||||
"allowed_protocols": "Erlaubte Protokolle",
|
||||
"archive": "Archiviert",
|
||||
"backup_mx": "Relay-Domain",
|
||||
"bcc": "BCC",
|
||||
"bcc_destination": "BCC-Ziel",
|
||||
@@ -1198,9 +1209,6 @@
|
||||
"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",
|
||||
@@ -1372,6 +1380,9 @@
|
||||
"app_links": "Änderungen an App-Links wurden gespeichert",
|
||||
"app_passwd_added": "App-Passwort wurde gespeichert",
|
||||
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
|
||||
"archive_settings_saved": "Archivierungseinstellungen gespeichert",
|
||||
"archive_test_ok": "Verbindungstest erfolgreich: Der Archivserver akzeptiert die Archivadresse",
|
||||
"archive_testmail_queued": "Test-E-Mail an das Archiv in die Queue gestellt (Queue-ID %s)",
|
||||
"bcc_deleted": "BCC-Map-Einträge gelöscht: %s",
|
||||
"bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
|
||||
"bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
|
||||
@@ -1415,9 +1426,6 @@
|
||||
"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",
|
||||
@@ -1788,6 +1796,7 @@
|
||||
"pushover_sound": "Ton"
|
||||
},
|
||||
"warning": {
|
||||
"archive_test_ok_missing": "Verbindungstest erfolgreich, aber der Archivserver kündigt %s nicht an: 8-Bit-E-Mails werden unverändert gesendet und eventuell abgelehnt",
|
||||
"cannot_delete_self": "Kann derzeit eingeloggten Benutzer nicht entfernen",
|
||||
"domain_added_sogo_failed": "Domain wurde hinzugefügt, aber SOGo konnte nicht neugestartet werden",
|
||||
"dovecot_restart_failed": "Dovecot wurde nicht erfolgreich neu gestartet, bitte prüfen Sie die Logs.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"alias_domains": "Add alias domains",
|
||||
"alias_external_goto": "Allow aliases with external goto domains",
|
||||
"app_passwds": "Manage app passwords",
|
||||
"archive": "Change archiving of domains and mailboxes",
|
||||
"bcc_maps": "BCC maps",
|
||||
"delimiter_action": "Delimiter action",
|
||||
"domain_desc": "Change domain description",
|
||||
@@ -11,7 +12,6 @@
|
||||
"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",
|
||||
@@ -67,7 +67,7 @@
|
||||
"hostname": "Host",
|
||||
"inactive": "Inactive",
|
||||
"internal": "Internal",
|
||||
"internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).",
|
||||
"internal_info": "Internal aliases are only accessible from the own domain or alias domains.",
|
||||
"sender_allowed": "Allow to send as this alias",
|
||||
"kind": "Kind",
|
||||
"mailbox_quota_def": "Default mailbox quota",
|
||||
@@ -143,6 +143,57 @@
|
||||
"app_links": "App links",
|
||||
"app_name": "App name",
|
||||
"apps_name": "\"mailcow Apps\" name",
|
||||
"archive": "Archiving",
|
||||
"archive_address": "Archive address",
|
||||
"archive_address_info": "mailcow sends a copy of every archived message to this address.",
|
||||
"archive_delivery": "Result",
|
||||
"archive_delivery_bounced": "Bounced",
|
||||
"archive_delivery_deferred": "Deferred",
|
||||
"archive_delivery_dropped": "Not archived",
|
||||
"archive_delivery_expired": "Expired, discarded",
|
||||
"archive_delivery_sent": "Delivered",
|
||||
"archive_delivery_softbounce": "Refused, retrying",
|
||||
"archive_details": "Answer of the archive / reason",
|
||||
"archive_enabled": "Enable archiving",
|
||||
"archive_enabled_info": "Archives the domains and mailboxes that have archiving enabled. Disabling stops new copies; copies already in the mail queue are still delivered.",
|
||||
"archive_header_from": "Header name for the envelope sender",
|
||||
"archive_header_rcpt": "Header name for the envelope recipients",
|
||||
"archive_host": "Archive server",
|
||||
"archive_host_info": "Leave empty to use the MX of the archive address domain.",
|
||||
"archive_info": "mailcow sends a copy of every accepted incoming, outgoing and internal message of archived mailboxes to an external archive by SMTP (journaling). Copies are queued and retried while the archive is unreachable; senders never get a bounce or delay notice for them.",
|
||||
"archive_log": "Archive log",
|
||||
"archive_log_info": "Copies sent to the archive, from the Postfix log in memory, and mails that were not archived. Search for the Message-ID of a mail.",
|
||||
"archive_message_id": "Message-ID",
|
||||
"archive_password": "SMTP password",
|
||||
"archive_password_remove": "Remove stored password",
|
||||
"archive_port": "Port",
|
||||
"archive_profile": "Profile",
|
||||
"archive_profile_archivecow": "archivecow (owners, envelope, signature)",
|
||||
"archive_profile_archivecow_info": "For archivecow: signed headers above the mail name the archived mailboxes it belongs to and the SMTP envelope (BCC recipients included). archivecow verifies the signature with the signing key.",
|
||||
"archive_profile_envelope": "Envelope sender and recipients",
|
||||
"archive_profile_envelope_info": "For other archives (e.g. MailPiler): two headers above the mail name the envelope sender and recipients, so the archive can assign BCC recipients too. The headers are not signed.",
|
||||
"archive_profile_none": "None (unchanged copy)",
|
||||
"archive_profile_none_info": "The mail goes to the archive unchanged. BCC recipients cannot be seen, the archive assigns the mail by its own headers (From, To, Cc) only.",
|
||||
"archive_queue_id": "Queue ID",
|
||||
"archive_save_first": "Save your changes first: the tests use the saved settings.",
|
||||
"archive_section_connection": "Connection",
|
||||
"archive_section_headers": "Archive headers",
|
||||
"archive_signing_secret": "Signing key",
|
||||
"archive_signing_secret_info": "Signs the archive headers (X-Archive-Signature). Must match the key in archivecow.",
|
||||
"archive_state_dropped": "%s mails were not archived in the last 24 hours, the last at %s: %s",
|
||||
"archive_state_off": "Archiving is disabled.",
|
||||
"archive_state_ok": "Archiving is working, last delivery to the archive: %s",
|
||||
"archive_state_ok_none": "Archiving is enabled, the log has no delivery to the archive yet",
|
||||
"archive_state_queued": "%s copies are waiting for the archive, the oldest since %s",
|
||||
"archive_state_unconfigured": "Archive address or signing key missing",
|
||||
"archive_test": "Test connection",
|
||||
"archive_test_info": "The tests use the saved settings. The test mail goes through the mail queue to the archive.",
|
||||
"archive_testmail": "Send test mail",
|
||||
"archive_tls": "TLS",
|
||||
"archive_tls_encrypt": "Required, no certificate check",
|
||||
"archive_tls_may": "If available",
|
||||
"archive_tls_verify": "Required, with certificate check",
|
||||
"archive_username": "SMTP username",
|
||||
"arrival_time": "Arrival time (server time)",
|
||||
"authed_user": "Auth. user",
|
||||
"ays": "Are you sure you want to proceed?",
|
||||
@@ -509,6 +560,19 @@
|
||||
"api_key_description_invalid": "API key description must not exceed 255 characters",
|
||||
"api_key_network_invalid": "Invalid IP address or network: %s",
|
||||
"api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there",
|
||||
"archive_address_invalid": "Invalid archive address",
|
||||
"archive_address_local": "The archive address must not be in a domain or alias domain of this mailcow",
|
||||
"archive_header_invalid": "Invalid header name: letters, digits and hyphens, starting with a letter",
|
||||
"archive_host_invalid": "Invalid archive server",
|
||||
"archive_incomplete": "Archiving requires an archive address and, for the archivecow profile, a signing key",
|
||||
"archive_password_invalid": "Invalid SMTP password: printable ASCII without spaces, at most 255 characters",
|
||||
"archive_port_invalid": "Invalid port",
|
||||
"archive_profile_invalid": "Invalid archive header profile",
|
||||
"archive_signing_secret_invalid": "Invalid signing key: printable ASCII without spaces, at most 255 characters",
|
||||
"archive_test_failed": "Connection test to the archive failed: %s",
|
||||
"archive_testmail_failed": "Test mail could not be queued: %s",
|
||||
"archive_tls_invalid": "Invalid TLS mode",
|
||||
"archive_username_invalid": "Invalid SMTP username",
|
||||
"group_address_invalid": "Group address %s is invalid or not in the domain of the group",
|
||||
"group_description_invalid": "The group description must not exceed 255 characters",
|
||||
"group_grant_invalid": "Invalid share for mailbox %s",
|
||||
@@ -519,14 +583,6 @@
|
||||
"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": "At most %s entries per list or selection are possible",
|
||||
"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",
|
||||
@@ -789,6 +845,15 @@
|
||||
"app_name": "App name",
|
||||
"app_passwd": "App password",
|
||||
"app_passwd_protocols": "Allowed protocols for app password",
|
||||
"archive": "Archiving",
|
||||
"archive_active": "Archived",
|
||||
"archive_domain": "Archive all mailboxes by default",
|
||||
"archive_domain_info": "Applies to mailboxes set to \"Domain default\".",
|
||||
"archive_inactive": "Not archived",
|
||||
"archive_info": "\"Domain default\" follows the domain setting. Archived mailboxes send a copy of every message they send or receive to the archive.",
|
||||
"archive_inherit": "Domain default",
|
||||
"archive_off": "Disabled",
|
||||
"archive_on": "Enabled",
|
||||
"backup_mx_options": "Relay options",
|
||||
"bcc_dest_format": "BCC destination must be a single valid email address.<br>If you need to send a copy to multiple addresses, create an alias and use it here.",
|
||||
"client_id": "Client ID",
|
||||
@@ -838,7 +903,7 @@
|
||||
"iam_unlink": "Unlink",
|
||||
"inactive": "Inactive",
|
||||
"internal": "Internal",
|
||||
"internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).",
|
||||
"internal_info": "Internal aliases are only accessible from the own domain or 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",
|
||||
@@ -978,7 +1043,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 logged-in senders of the domain",
|
||||
"internal": "Only accept mail from senders of the domain",
|
||||
"list_error": "Groups could not be loaded",
|
||||
"managed_alias": "Managed by group %s",
|
||||
"members": "Members",
|
||||
@@ -1045,61 +1110,6 @@
|
||||
"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 in the policy's domain; 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_receive": "Addresses or domains, one per line, and groups of the domain. Allowed addresses and domains of this mailcow count for logged-in senders only, blocked ones also for the envelope sender address. External senders are recognised by the envelope sender address.",
|
||||
"entries_info_send": "Addresses or domains, one per line, and groups of the domain.",
|
||||
"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_address": "Refused: this form of the address routes through another address and is not allowed.",
|
||||
"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 your domains and was tested as not logged in.",
|
||||
"test_recipient": "Recipient",
|
||||
"test_run": "Test",
|
||||
"test_sender": "Sender",
|
||||
"view": "View"
|
||||
},
|
||||
"mailbox": {
|
||||
"action": "Action",
|
||||
"activate": "Activate",
|
||||
@@ -1126,6 +1136,7 @@
|
||||
"allow_from_smtp": "Only allow these IPs to use <b>SMTP</b>",
|
||||
"allow_from_smtp_info": "Leave empty to allow all senders.<br>IPv4/IPv6 addresses and networks.",
|
||||
"allowed_protocols": "Allowed protocols",
|
||||
"archive": "Archived",
|
||||
"backup_mx": "Relay domain",
|
||||
"bcc": "BCC",
|
||||
"bcc_destination": "BCC destination",
|
||||
@@ -1205,9 +1216,6 @@
|
||||
"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",
|
||||
@@ -1379,6 +1387,9 @@
|
||||
"app_links": "Saved changes to app links",
|
||||
"app_passwd_added": "Added new app password",
|
||||
"app_passwd_removed": "Removed app password ID %s",
|
||||
"archive_settings_saved": "Archive settings saved",
|
||||
"archive_test_ok": "Connection test passed: the archive server accepts the archive address",
|
||||
"archive_testmail_queued": "Test mail to the archive queued (queue ID %s)",
|
||||
"bcc_deleted": "BCC map entries deleted: %s",
|
||||
"bcc_edited": "BCC map entry %s edited",
|
||||
"bcc_saved": "BCC map entry saved",
|
||||
@@ -1422,9 +1433,6 @@
|
||||
"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",
|
||||
@@ -1795,6 +1803,7 @@
|
||||
"years": "years"
|
||||
},
|
||||
"warning": {
|
||||
"archive_test_ok_missing": "Connection test passed, but the archive server does not announce %s: 8-bit messages are sent unchanged and may be rejected",
|
||||
"cannot_delete_self": "Cannot delete logged in user",
|
||||
"domain_added_sogo_failed": "Added domain but failed to restart SOGo, please check your server logs.",
|
||||
"dovecot_restart_failed": "Dovecot failed to restart, please check the logs",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</ul>
|
||||
</li>
|
||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-routing" aria-selected="false" aria-controls="tab-routing" role="tab" data-bs-toggle="tab">{{ lang.admin.routing }}</button></li>
|
||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-archive" aria-selected="false" aria-controls="tab-archive" role="tab" data-bs-toggle="tab">{{ lang.admin.archive }}</button></li>
|
||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-sys-mails" aria-selected="false" aria-controls="tab-sys-mails" role="tab" data-bs-toggle="tab">{{ lang.admin.sys_mails }}</button></li>
|
||||
<li class="nav-item dropdown">
|
||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">{{ lang.admin.rspamd_global_filters }}</a>
|
||||
@@ -47,6 +48,7 @@
|
||||
{% include 'admin/tab-config-oauth2.twig' %}
|
||||
{% include 'admin/tab-config-rspamd.twig' %}
|
||||
{% include 'admin/tab-routing.twig' %}
|
||||
{% include 'admin/tab-archive.twig' %}
|
||||
{% include 'admin/tab-config-dkim.twig' %}
|
||||
{% include 'admin/tab-config-fwdhosts.twig' %}
|
||||
{% include 'admin/tab-config-f2b.twig' %}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<div class="tab-pane fade" id="tab-archive" role="tabpanel" aria-labelledby="tab-archive">
|
||||
<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-archive" data-bs-toggle="collapse" aria-controls="collapse-tab-archive">
|
||||
{{ lang.admin.archive }}
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.admin.archive }}</span>
|
||||
</div>
|
||||
<div id="collapse-tab-archive" class="card-body collapse" data-bs-parent="#admin-content">
|
||||
<div class="row">
|
||||
<p class="offset-md-3 col-12 col-md-9 mb-4">{{ lang.admin.archive_info }}</p>
|
||||
</div>
|
||||
<form class="form-horizontal" role="form" data-id="archive" method="post" autocomplete="off">
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_enabled">{{ lang.admin.archive_enabled }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 d-flex align-items-center">
|
||||
<input type="hidden" name="enabled" value="0">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" role="switch" id="archive_enabled" name="enabled" value="1"{% if archive_settings.enabled == 1 %} checked{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="offset-md-3 col-12 col-md-9">
|
||||
<small class="text-muted">{{ lang.admin.archive_enabled_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if archive_settings.enabled == 1 %}
|
||||
{# Filled by js/site/archive.js: one line per problem; details in the archive log and the queue manager #}
|
||||
<div class="row mb-2" id="archive_status">
|
||||
<div class="offset-md-3 col-12 col-md-9 col-lg-7">
|
||||
<div id="archive_state"><p class="text-muted mb-2">{{ lang.datatables.loadingRecords }}</p></div>
|
||||
<small class="d-block">
|
||||
<a href="/admin/dashboard" id="archive_log_link"><i class="bi bi-journal-text"></i> {{ lang.admin.archive_log }}</a>
|
||||
<a href="/admin/queue" class="ms-3"><i class="bi bi-inboxes"></i> {{ lang.queue.queue_manager }}</a>
|
||||
<a href="#" class="ms-3" id="archive_refresh"><i class="bi bi-arrow-clockwise"></i> {{ lang.admin.refresh }}</a>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<legend class="fs-5 mt-4"><i class="bi bi-plug"></i> {{ lang.admin.archive_section_connection }}</legend><hr>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_address">{{ lang.admin.archive_address }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<input type="email" class="form-control" id="archive_address" name="address" placeholder="journal@archive.example.com" value="{{ archive_settings.address }}">
|
||||
</div>
|
||||
<div class="offset-md-3 col-12 col-md-9">
|
||||
<small class="text-muted">{{ lang.admin.archive_address_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_host">{{ lang.admin.archive_host }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="archive_host" name="host" placeholder="archive.example.com" value="{{ archive_settings.host }}">
|
||||
<span class="input-group-text">{{ lang.admin.archive_port }}</span>
|
||||
<input type="number" class="form-control" id="archive_port" name="port" min="1" max="65535" style="max-width: 7rem" aria-label="{{ lang.admin.archive_port }}" value="{{ archive_settings.port }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="offset-md-3 col-12 col-md-9">
|
||||
<small class="text-muted">{{ lang.admin.archive_host_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_tls">{{ lang.admin.archive_tls }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<select class="full-width-select form-control" id="archive_tls" name="tls">
|
||||
{% for mode in ['encrypt', 'verify', 'may'] %}
|
||||
<option value="{{ mode }}"{% if archive_settings.tls == mode %} selected{% endif %}>{{ attribute(lang.admin, 'archive_tls_' ~ mode) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_username">{{ lang.admin.archive_username }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<input type="text" class="form-control" id="archive_username" name="username" autocomplete="off" value="{{ archive_settings.username }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_password">{{ lang.admin.archive_password }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<input type="password" class="form-control" id="archive_password" name="password" autocomplete="new-password"
|
||||
placeholder="{% if archive_settings.has_password == 1 %}{{ lang.edit.unchanged_if_empty }}{% endif %}">
|
||||
{% if archive_settings.has_password == 1 %}
|
||||
<div class="form-check mt-1">
|
||||
<input class="form-check-input" type="checkbox" id="archive_password_remove" name="password_remove" value="1">
|
||||
<label class="form-check-label" for="archive_password_remove">{{ lang.admin.archive_password_remove }}</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<legend class="fs-5 mt-4"><i class="bi bi-file-earmark-text"></i> {{ lang.admin.archive_section_headers }}</legend><hr>
|
||||
<div class="row mb-2">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_profile">{{ lang.admin.archive_profile }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<select class="full-width-select form-control" id="archive_profile" name="profile">
|
||||
{% for profile in ['archivecow', 'envelope', 'none'] %}
|
||||
<option value="{{ profile }}"{% if archive_settings.profile == profile %} selected{% endif %}>{{ attribute(lang.admin, 'archive_profile_' ~ profile) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="offset-md-3 col-12 col-md-9">
|
||||
{% for profile in ['archivecow', 'envelope', 'none'] %}
|
||||
<small class="text-muted archive-profile-info archive-profile-info-{{ profile }}{% if archive_settings.profile != profile %} d-none{% endif %}">{{ attribute(lang.admin, 'archive_profile_' ~ profile ~ '_info') }}</small>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{# Fields of the other profiles are hidden and disabled, so their stored values are kept #}
|
||||
{% set archivecow = archive_settings.profile == 'archivecow' %}
|
||||
{% set envelope = archive_settings.profile == 'envelope' %}
|
||||
<div class="row mb-2 archive-profile archive-profile-archivecow{% if not archivecow %} d-none{% endif %}">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_signing_secret">{{ lang.admin.archive_signing_secret }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<input type="password" class="form-control" id="archive_signing_secret" name="signing_secret" autocomplete="new-password"
|
||||
placeholder="{% if archive_settings.has_signing_secret == 1 %}{{ lang.edit.unchanged_if_empty }}{% endif %}"{% if not archivecow %} disabled{% endif %}>
|
||||
</div>
|
||||
<div class="offset-md-3 col-12 col-md-9">
|
||||
<small class="text-muted">{{ lang.admin.archive_signing_secret_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 archive-profile archive-profile-envelope{% if not envelope %} d-none{% endif %}">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_header_from">{{ lang.admin.archive_header_from }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<input type="text" class="form-control" id="archive_header_from" name="header_from" value="{{ archive_settings.header_from }}"{% if not envelope %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 archive-profile archive-profile-envelope{% if not envelope %} d-none{% endif %}">
|
||||
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||
<label class="control-label" for="archive_header_rcpt">{{ lang.admin.archive_header_rcpt }}:</label>
|
||||
</div>
|
||||
<div class="col-12 col-md-9 col-lg-5">
|
||||
<input type="text" class="form-control" id="archive_header_rcpt" name="header_rcpt" value="{{ archive_settings.header_rcpt }}"{% if not envelope %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="mt-4">
|
||||
<div class="row mb-2">
|
||||
<div class="offset-md-3 col-12 col-md-9">
|
||||
{% set archive_ready = archive_settings.address and (archive_settings.profile != 'archivecow' or archive_settings.has_signing_secret == 1) %}
|
||||
<button type="button" class="btn btn-sm d-block d-sm-inline btn-success mb-1" data-action="edit_selected" data-item="archive" data-id="archive" data-api-url='edit/archive' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
|
||||
<button type="button" class="btn btn-sm d-block d-sm-inline btn-secondary mb-1 archive-test" data-action="edit_selected" data-item="archive" data-id="archive_test" data-api-url='edit/archive' data-api-attr='{"action":"test"}'{% if not archive_ready %} disabled{% endif %}><i class="bi bi-plug"></i> {{ lang.admin.archive_test }}</button>
|
||||
<button type="button" class="btn btn-sm d-block d-sm-inline btn-secondary mb-1 archive-test" data-action="edit_selected" data-item="archive" data-id="archive_test" data-api-url='edit/archive' data-api-attr='{"action":"testmail"}'{% if not archive_ready %} disabled{% endif %}><i class="bi bi-envelope"></i> {{ lang.admin.archive_testmail }}</button>
|
||||
<small class="text-muted d-block" id="archive_test_info">{{ lang.admin.archive_test_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,6 +17,7 @@
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-logs" aria-selected="false" aria-controls="tab-api-logs" role="tab" data-bs-toggle="tab">API</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-rl" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Ratelimits</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-cron-logs" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Crontasks</button></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-archive-logs" aria-selected="false" aria-controls="tab-archive-logs" role="tab" data-bs-toggle="tab">{{ lang.admin.archive }}</button></li>
|
||||
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.external_logs }}</span></li>
|
||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-rspamd-history" aria-selected="false" aria-controls="tab-rspamd-history" role="tab" data-bs-toggle="tab">Rspamd</button></li>
|
||||
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.static_logs }}</span></li>
|
||||
@@ -547,6 +548,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div role="tabpanel" class="tab-pane" id="tab-archive-logs">
|
||||
<div class="debug-log-info">{{ lang.admin.archive_log_info }}</div>
|
||||
<div class="card">
|
||||
<div class="card-header d-flex align-items-center fs-5">
|
||||
<span class="mt-2 ms-2">{{ lang.admin.archive }}</span>
|
||||
<div class="btn-group ms-auto">
|
||||
<button class="btn btn-sm btn-secondary refresh_table" data-table="archive_log">{{ lang.admin.refresh }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table id="archive_log" class="table table-striped dt-responsive w-100"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div> <!-- /tab-content -->
|
||||
</div> <!-- /col-md-12 -->
|
||||
</div> <!-- /row -->
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<input type="hidden" value="0" name="active">
|
||||
<input type="hidden" value="0" name="backupmx">
|
||||
<input type="hidden" value="0" name="gal">
|
||||
<input type="hidden" value="0" name="archive">
|
||||
<input type="hidden" value="0" name="relay_all_recipients">
|
||||
<input type="hidden" value="0" name="relay_unknown_only">
|
||||
|
||||
@@ -70,6 +71,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row{% if not archive_enabled %} d-none{% endif %}">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="archive"{% if template.attributes.archive == '1' %} checked{% endif %}> {{ lang.edit.archive_domain }}</label>
|
||||
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<input type="hidden" value="0" name="active">
|
||||
<input type="hidden" value="0" name="backupmx">
|
||||
<input type="hidden" value="0" name="gal">
|
||||
<input type="hidden" value="0" name="archive">
|
||||
<input type="hidden" value="0" name="relay_all_recipients">
|
||||
<input type="hidden" value="0" name="relay_unknown_only">
|
||||
<div class="row mb-4">
|
||||
@@ -133,6 +134,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="archive"{% if result.archive == '1' %} checked{% endif %}{% if acl.archive != 1 %} disabled{% endif %}> {{ lang.edit.archive_domain }}</label>
|
||||
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
{# 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; without
|
||||
group choices (none in the domain, no access to groups) the group fields are left out and stay unchanged. #}
|
||||
{% 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">{{ attribute(lang.mail_policy, 'entries_info_' ~ side) }}</small>
|
||||
{% if groups %}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</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>
|
||||
{% if groups %}
|
||||
<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>
|
||||
{% endif %}
|
||||
@@ -1,46 +0,0 @@
|
||||
{% 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 %}
|
||||
@@ -114,6 +114,17 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}">
|
||||
<label class="control-label col-sm-2" for="archive">{{ lang.edit.archive }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" name="archive" id="archive">
|
||||
<option value="inherit"{% if template.attributes.archive not in ['0', '1'] %} selected{% endif %}>{{ lang.edit.archive_inherit }}</option>
|
||||
<option value="1"{% if template.attributes.archive == '1' %} selected{% endif %}>{{ lang.edit.archive_on }}</option>
|
||||
<option value="0"{% if template.attributes.archive == '0' %} selected{% endif %}>{{ lang.edit.archive_off }}</option>
|
||||
</select>
|
||||
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2">ACL</label>
|
||||
<div class="col-sm-10">
|
||||
|
||||
@@ -331,6 +331,19 @@
|
||||
<small class="text-muted">{{ lang.edit.allow_from_smtp_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||
<label class="control-label col-sm-2" for="archive">{{ lang.edit.archive }}
|
||||
<br><span class="badge {% if result.archive == 1 %}bg-success{% else %}bg-secondary{% endif %}">{% if result.archive == 1 %}{{ lang.edit.archive_active }}{% else %}{{ lang.edit.archive_inactive }}{% endif %}</span>
|
||||
</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" name="archive" id="archive"{% if acl.archive != 1 %} disabled{% endif %}>
|
||||
<option value="inherit"{% if result.attributes.archive not in ['0', '1'] %} selected{% endif %}>{{ lang.edit.archive_inherit }}</option>
|
||||
<option value="1"{% if result.attributes.archive == '1' %} selected{% endif %}>{{ lang.edit.archive_on }}</option>
|
||||
<option value="0"{% if result.attributes.archive == '0' %} selected{% endif %}>{{ lang.edit.archive_off }}</option>
|
||||
</select>
|
||||
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<hr>
|
||||
<div class="row">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
|
||||
@@ -36,20 +36,10 @@
|
||||
{% 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">
|
||||
@@ -69,9 +59,6 @@
|
||||
{% 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' %}
|
||||
@@ -88,13 +75,13 @@
|
||||
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 }};
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
var pagination_size = Math.trunc('{{ pagination_size }}');
|
||||
var role = '{{ role }}';
|
||||
var archive_enabled = {{ archive_enabled ? 1 : 0 }};
|
||||
var is_dual = {{ is_dual }};
|
||||
var ALLOW_ADMIN_EMAIL_LOGIN = {{ allow_admin_email_login }};
|
||||
</script>
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
<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>
|
||||
@@ -1,50 +0,0 @@
|
||||
<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>
|
||||
@@ -39,7 +39,7 @@
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2 text-sm-end text-sm-end" for="description">{{ lang.mailbox.template }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select data-live-search="true" id="mailbox_templates" class="form-control" title="{{ lang.mailbox.template }}">
|
||||
<select data-live-search="true" id="mailbox_templates" name="template" class="form-control" title="{{ lang.mailbox.template }}">
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,6 +154,17 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addMailbox_archive">{{ lang.edit.archive }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" name="archive" id="addMailbox_archive"{% if acl.archive != 1 %} disabled{% endif %}>
|
||||
<option value="inherit" selected>{{ lang.edit.archive_inherit }}</option>
|
||||
<option value="1">{{ lang.edit.archive_on }}</option>
|
||||
<option value="0">{{ lang.edit.archive_off }}</option>
|
||||
</select>
|
||||
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
||||
<div class="col-sm-10">
|
||||
@@ -360,6 +371,17 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addMailboxTemplate_archive">{{ lang.edit.archive }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control" name="archive" id="addMailboxTemplate_archive">
|
||||
<option value="inherit" selected>{{ lang.edit.archive_inherit }}</option>
|
||||
<option value="1">{{ lang.edit.archive_on }}</option>
|
||||
<option value="0">{{ lang.edit.archive_off }}</option>
|
||||
</select>
|
||||
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
||||
<div class="col-sm-10">
|
||||
@@ -451,6 +473,7 @@
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal" data-cached-form="true" data-id="add_domain" role="form">
|
||||
<input type="hidden" value="0" name="gal">
|
||||
<input type="hidden" value="0" name="archive">
|
||||
<input type="hidden" value="0" name="active">
|
||||
<input type="hidden" value="0" name="backupmx">
|
||||
<input type="hidden" value="0" name="relay_all_recipients">
|
||||
@@ -525,6 +548,14 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="archive" id="addDomain_archive"> {{ lang.edit.archive_domain }}</label>
|
||||
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
@@ -664,6 +695,14 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row{% if not archive_enabled %} d-none{% endif %}">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="archive"> {{ lang.edit.archive_domain }}</label>
|
||||
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
@@ -1362,6 +1401,3 @@
|
||||
{% if acl.mailbox_groups == 1 %}
|
||||
{% include "modals/mailbox_group.twig" %}
|
||||
{% endif %}
|
||||
{% if acl.mail_policies == 1 %}
|
||||
{% include "modals/mail_policy.twig" %}
|
||||
{% endif %}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Version of the database schema
|
||||
x-db-version: &db_version DB_VERSION=07112026_1800
|
||||
x-db-version: &db_version DB_VERSION=06112026_1800
|
||||
|
||||
services:
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Controller;
|
||||
|
||||
use Mailcow\Archive\Controller\ArchiveApiController;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Service\JournalService;
|
||||
use Mailcow\Archive\Service\MailboxListService;
|
||||
use Mailcow\Archive\Service\SettingsService;
|
||||
use Mailcow\Archive\Service\StatusService;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ArchiveApiControllerTest extends TestCase
|
||||
{
|
||||
private SettingsService&MockObject $settings;
|
||||
private StatusService&MockObject $status;
|
||||
private JournalService&MockObject $journal;
|
||||
private MailboxListService&MockObject $mailboxes;
|
||||
private ArchiveApiController $api;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$this->settings = $this->createMock(SettingsService::class);
|
||||
$this->status = $this->createMock(StatusService::class);
|
||||
$this->journal = $this->createMock(JournalService::class);
|
||||
$this->mailboxes = $this->createMock(MailboxListService::class);
|
||||
$this->api = new ArchiveApiController($this->settings, $this->status, $this->journal, $this->mailboxes,
|
||||
new ApiResponse(['password', 'signing_secret']));
|
||||
}
|
||||
|
||||
/** Messages written to the session as [type, msg] */
|
||||
private function messages(): array
|
||||
{
|
||||
return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []);
|
||||
}
|
||||
|
||||
public function testSaveLogsWithoutSecrets(): void
|
||||
{
|
||||
$this->settings->expects($this->once())->method('save')->willReturn(new Settings());
|
||||
|
||||
$this->assertTrue($this->api->edit(['enabled' => '1', 'username' => 'mailcow', 'password' => 'pw', 'signing_secret' => 'key']));
|
||||
|
||||
$this->assertSame([[
|
||||
'type' => 'success',
|
||||
'log' => ['archive', 'edit', 'settings', ['enabled' => '1', 'username' => 'mailcow', 'password' => '*', 'signing_secret' => '*']],
|
||||
'msg' => 'archive_settings_saved',
|
||||
]], $_SESSION['return']);
|
||||
}
|
||||
|
||||
public function testActionsAndErrors(): void
|
||||
{
|
||||
$this->settings->method('test')->willReturnOnConsecutiveCalls(
|
||||
['archive_test_ok_missing', '8BITMIME'],
|
||||
$this->throwException(new UserError('archive_test_failed', ['archive.example:25 connect: Connection refused'])),
|
||||
);
|
||||
$this->journal->method('testMail')->willReturn('4XyZ<1>');
|
||||
$this->settings->method('save')->willThrowException(new PDOException('gone away'));
|
||||
|
||||
$this->assertTrue($this->api->edit(['action' => 'test']));
|
||||
$this->assertFalse($this->api->edit(['action' => 'test']));
|
||||
$this->assertTrue($this->api->edit(['action' => 'testmail']));
|
||||
$this->assertFalse($this->api->edit(['address' => 'journal@archive.example']));
|
||||
|
||||
$this->assertSame([
|
||||
['warning', ['archive_test_ok_missing', '8BITMIME']],
|
||||
['danger', ['archive_test_failed', 'archive.example:25 connect: Connection refused']],
|
||||
['success', ['archive_testmail_queued', '4XyZ<1>']],
|
||||
['danger', ['mysql_error', 'gone away']],
|
||||
], $this->messages());
|
||||
}
|
||||
|
||||
public function testActionsNeedWriteAccess(): void
|
||||
{
|
||||
$this->journal->method('testMail')->willThrowException(new AccessDenied());
|
||||
$this->settings->expects($this->never())->method('save');
|
||||
|
||||
$this->assertFalse($this->api->edit(['action' => 'testmail']));
|
||||
$this->assertSame([['danger', 'access_denied']], $this->messages());
|
||||
}
|
||||
|
||||
public function testReadsAreEmptyWhenDenied(): void
|
||||
{
|
||||
$this->settings->method('get')->willThrowException(new AccessDenied());
|
||||
$this->status->method('status')->willThrowException(new AccessDenied());
|
||||
$this->status->method('log')->willThrowException(new AccessDenied());
|
||||
$this->mailboxes->method('archived')->willReturn(['enabled' => 1, 'mailboxes' => []]);
|
||||
|
||||
$this->assertSame([], $this->api->getSettings());
|
||||
$this->assertSame([], $this->api->getStatus());
|
||||
$this->assertSame([], $this->api->getLog());
|
||||
$this->assertSame(['enabled' => 1, 'mailboxes' => []], $this->api->getMailboxes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Model;
|
||||
|
||||
use Mailcow\Archive\Model\Mailbox;
|
||||
use Mailcow\Archive\Service\MailboxListService;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class MailboxTest extends TestCase
|
||||
{
|
||||
public static function settings(): array
|
||||
{
|
||||
return [
|
||||
'inherit, domain on' => ['inherit', 1, true],
|
||||
'inherit, domain off' => ['inherit', 0, false],
|
||||
'missing attribute' => [null, 1, true],
|
||||
'unknown value' => ['yes', 0, false],
|
||||
'on' => ['1', 0, true],
|
||||
'on as number' => [1, 0, true],
|
||||
'off' => ['0', 1, false],
|
||||
'off as number' => [0, 1, false],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('settings')]
|
||||
public function testEffective(mixed $archive, int $domainArchive, bool $archived): void
|
||||
{
|
||||
$this->assertSame($archived, Mailbox::effective($archive, $domainArchive));
|
||||
}
|
||||
|
||||
public function testListOfArchivedMailboxes(): void
|
||||
{
|
||||
$lookup = new FakeArchiveLookup();
|
||||
$lookup->mailboxes = ['b@firma.de' => ['inherit', 1], 'a@other.de' => ['1', 0], 'c@firma.de' => ['0', 1]];
|
||||
$settings = new Settings();
|
||||
$repo = $this->createMock(SettingsRepository::class);
|
||||
$repo->method('load')->willReturnCallback(fn(): Settings => $settings);
|
||||
$readOnly = new Context('admin', 'API', [], false, 'ro');
|
||||
|
||||
$this->assertSame(['enabled' => 0, 'mailboxes' => []], (new MailboxListService($lookup, $repo, $readOnly))->archived());
|
||||
|
||||
$settings->enabled = true;
|
||||
$this->assertSame(['enabled' => 1, 'mailboxes' => [
|
||||
['username' => 'b@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'domain'],
|
||||
['username' => 'a@other.de', 'domain' => 'other.de', 'name' => '', 'active' => 1, 'source' => 'mailbox'],
|
||||
]], (new MailboxListService($lookup, $repo, $readOnly))->archived());
|
||||
|
||||
$this->expectException(UserError::class);
|
||||
(new MailboxListService($lookup, $repo, FakeContext::domainAdmin()))->archived();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Model;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SettingsTest extends TestCase
|
||||
{
|
||||
public static function nexthops(): array
|
||||
{
|
||||
return [
|
||||
'mx of the address domain' => ['', 25, 'archive.example'],
|
||||
'mx, other port' => ['', 2525, 'archive.example:2525'],
|
||||
'host' => ['smtp.archive.example', 25, '[smtp.archive.example]:25'],
|
||||
'ipv4' => ['192.0.2.5', 587, '[192.0.2.5]:587'],
|
||||
'ipv6' => ['2001:db8::25', 25, '[ipv6:2001:db8::25]:25'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('nexthops')]
|
||||
public function testNexthop(string $host, int $port, string $nexthop): void
|
||||
{
|
||||
$settings = new Settings();
|
||||
$settings->address = 'journal@archive.example';
|
||||
$settings->host = $host;
|
||||
$settings->port = $port;
|
||||
|
||||
$this->assertSame($nexthop, $settings->nexthop());
|
||||
}
|
||||
|
||||
public function testCanDeliverAndNoSecretsInTheApi(): void
|
||||
{
|
||||
$settings = new Settings();
|
||||
$this->assertFalse($settings->canDeliver());
|
||||
$settings->address = 'journal@archive.example';
|
||||
$this->assertFalse($settings->canDeliver());
|
||||
$settings->profile = 'envelope';
|
||||
$this->assertTrue($settings->canDeliver());
|
||||
|
||||
$settings->password = 'password-value';
|
||||
$settings->signingKey = 'signing-key-value';
|
||||
$api = json_encode($settings->toArray());
|
||||
$this->assertStringNotContainsString('password-value', $api);
|
||||
$this->assertStringNotContainsString('signing-key-value', $api);
|
||||
$this->assertSame([1, 1], [$settings->toArray()['has_password'], $settings->toArray()['has_signing_secret']]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Redis;
|
||||
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Tests\Support\FakeRedis;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ArchiveStateTest extends TestCase
|
||||
{
|
||||
private FakeRedis $redis;
|
||||
private ArchiveState $state;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->redis = new FakeRedis();
|
||||
$this->state = new ArchiveState($this->redis);
|
||||
}
|
||||
|
||||
/** A Postfix log entry as syslog-ng pushes it (newest first) */
|
||||
private function log(string $program, string $message, int $time): void
|
||||
{
|
||||
$this->redis->lPush('POSTFIX_MAILLOG', json_encode(['time' => $time, 'program' => $program, 'message' => $message]));
|
||||
}
|
||||
|
||||
public function testSwitchIsMirroredForRspamd(): void
|
||||
{
|
||||
$this->state->setEnabled(true);
|
||||
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||
$this->state->setEnabled(false);
|
||||
$this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||
}
|
||||
|
||||
public function testDroppedCopiesAreCountedAndTheLast100Listed(): void
|
||||
{
|
||||
for ($i = 1; $i <= 105; $i++) {
|
||||
$this->state->dropped('Q' . $i, 'owner lookup failed', 'm' . $i . '@firma.de');
|
||||
}
|
||||
|
||||
$stats = $this->state->droppedStats(2);
|
||||
$this->assertSame(105, $stats['count']);
|
||||
$this->assertSame(['Q105', 'Q104'], array_column($stats['recent'], 'qid'));
|
||||
$this->assertSame(['owner lookup failed', 'm105@firma.de'], [$stats['recent'][0]['reason'], $stats['recent'][0]['message_id']]);
|
||||
$this->assertCount(100, $this->redis->lists['ARCHIVE_DROPPED_LOG']);
|
||||
}
|
||||
|
||||
public function testEventsOfTheArchiveServiceOnly(): void
|
||||
{
|
||||
$this->log('postfix/cleanup', '1A2B: message-id=<orig@extern.example>', 1700000000);
|
||||
$this->log('postfix/archive/smtp', '1A2B: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, delay=0.2, dsn=2.0.0, '
|
||||
. 'status=sent (250 2.0.0 Ok: queued as X1)', 1700000001);
|
||||
$this->log('postfix/smtp', '3C4D: to=<someone@extern.example>, relay=mx.extern.example[192.0.2.9]:25, dsn=2.0.0, status=sent (250 ok)', 1700000002);
|
||||
// notices about copies are dropped by archive_discard, they are no deliveries
|
||||
$this->log('postfix/archive/discard', '5E6F: to=<archive-bounce@localhost>, relay=none, dsn=2.0.0, status=sent (discards mail)', 1700000003);
|
||||
$this->log('postfix/archive/smtp', 'connect to archive.example[192.0.2.5]:25: Connection refused', 1700000004);
|
||||
$this->log('postfix/archive/smtp', '7A8B: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=5.1.1, '
|
||||
. 'status=SOFTBOUNCE (host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command))', 1700000005);
|
||||
$this->redis->lPush('POSTFIX_MAILLOG', 'no json');
|
||||
|
||||
$this->assertSame([
|
||||
['time' => 1700000005, 'status' => 'softbounce', 'qid' => '7A8B', 'message_id' => '',
|
||||
'text' => 'host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command)'],
|
||||
['time' => 1700000001, 'status' => 'sent', 'qid' => '1A2B', 'message_id' => 'orig@extern.example', 'text' => '250 2.0.0 Ok: queued as X1'],
|
||||
], $this->state->events());
|
||||
$this->assertCount(1, $this->state->events(1));
|
||||
}
|
||||
|
||||
public function testWithoutRedisNothingHappens(): void
|
||||
{
|
||||
$state = new ArchiveState(null);
|
||||
$state->setEnabled(true);
|
||||
$state->dropped('Q1', 'reason');
|
||||
|
||||
$this->assertSame(['count' => 0, 'recent' => []], $state->droppedStats());
|
||||
$this->assertSame([], $state->events());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Journal;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Service\HeaderBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class HeaderBuilderTest extends TestCase
|
||||
{
|
||||
private static function settings(string $profile): Settings
|
||||
{
|
||||
$settings = new Settings();
|
||||
$settings->profile = $profile;
|
||||
$settings->signingKey = 'key';
|
||||
return $settings;
|
||||
}
|
||||
|
||||
private static function journal(array $rcpts = ['anna@firma.de', 'bcc@extern.example'], string $from = 'x@extern.example'): Journal
|
||||
{
|
||||
return new Journal(['anna@firma.de'], $rcpts, $from, '4XyZ1', 'internal', 'smtp', 1700000000.25, str_repeat('a', 64));
|
||||
}
|
||||
|
||||
public function testArchivecowHeadersAndSignature(): void
|
||||
{
|
||||
$block = (new HeaderBuilder())->build(self::settings('archivecow'), self::journal());
|
||||
|
||||
$this->assertSame("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de,bcc@extern.example\r\n"
|
||||
. "X-Archive-From: x@extern.example\r\nX-Archive-Queue-ID: 4XyZ1\r\nX-Archive-Received: 2023-11-14T22:13:20.250Z\r\n"
|
||||
. "X-Archive-Direction: internal\r\nX-Archive-Source: smtp\r\nX-Archive-SHA256: " . str_repeat('a', 64) . "\r\n",
|
||||
substr($block, 0, strpos($block, 'X-Archive-Signature:')));
|
||||
$signed = implode("\n", ['x-archive-owner:anna@firma.de', 'x-archive-rcpt:anna@firma.de,bcc@extern.example',
|
||||
'x-archive-from:x@extern.example', 'x-archive-queue-id:4XyZ1', 'x-archive-received:2023-11-14T22:13:20.250Z',
|
||||
'x-archive-direction:internal', 'x-archive-source:smtp', 'x-archive-sha256:' . str_repeat('a', 64)]);
|
||||
$this->assertStringEndsWith("X-Archive-Signature: v=1; a=hmac-sha256; s=" . hash_hmac('sha256', $signed, 'key') . "\r\n", $block);
|
||||
}
|
||||
|
||||
/** Fixed vector shared with archivecow: if this changes, every archive that verifies the signature refuses the copies */
|
||||
public function testWireFormatIsStable(): void
|
||||
{
|
||||
$message = "From: sender@example.com\r\nTo: anna.schmidt@example.org\r\nSubject: Test\r\nMessage-ID: <1@example.com>\r\n\r\nHallo.\r\n";
|
||||
$settings = self::settings('archivecow');
|
||||
$settings->signingKey = 'test-signing-key';
|
||||
$journal = new Journal(['anna.schmidt@example.org', 'buchhaltung@example.org'],
|
||||
['anna.schmidt@example.org', 'buchhaltung@example.org', 'extern.partner@example.net'],
|
||||
'sender@example.com', '4ZxYw1abcDz9', 'in', 'smtp', 1793983297.25, hash('sha256', $message));
|
||||
|
||||
$this->assertSame('0042f6cd8a846e5ce8f4a57c97121324c1213380fc204736d15ffe5693a9ef5c', hash('sha256', $message));
|
||||
$this->assertSame("X-Archive-Owner: anna.schmidt@example.org,buchhaltung@example.org\r\n"
|
||||
. "X-Archive-Rcpt: anna.schmidt@example.org,buchhaltung@example.org,\r\n extern.partner@example.net\r\n"
|
||||
. "X-Archive-From: sender@example.com\r\n"
|
||||
. "X-Archive-Queue-ID: 4ZxYw1abcDz9\r\n"
|
||||
. "X-Archive-Received: 2026-11-06T16:41:37.250Z\r\n"
|
||||
. "X-Archive-Direction: in\r\n"
|
||||
. "X-Archive-Source: smtp\r\n"
|
||||
. "X-Archive-SHA256: 0042f6cd8a846e5ce8f4a57c97121324c1213380fc204736d15ffe5693a9ef5c\r\n"
|
||||
. "X-Archive-Signature: v=1; a=hmac-sha256; s=443c927e165fd1f418fdfde2be54645d3201ef3b17d9e74ed3569d937fcf84d1\r\n",
|
||||
(new HeaderBuilder())->build($settings, $journal));
|
||||
}
|
||||
|
||||
public function testLongListsAreFoldedAndSignedUnfolded(): void
|
||||
{
|
||||
$rcpts = array_map(fn(int $i): string => sprintf('recipient%04d@extern.example', $i), range(1, 200));
|
||||
$block = (new HeaderBuilder())->build(self::settings('archivecow'), self::journal($rcpts));
|
||||
|
||||
foreach (explode("\r\n", $block) as $line) {
|
||||
$this->assertLessThanOrEqual(78 + 30, strlen($line));
|
||||
}
|
||||
preg_match('/^X-Archive-Rcpt: (.*?)\r\n(?=\S)/ms', $block, $m);
|
||||
$this->assertSame($rcpts, explode(',', preg_replace('/\r\n /', '', $m[1])));
|
||||
// the signature covers the values without whitespace, so folding does not matter
|
||||
$this->assertStringContainsString(hash_hmac('sha256', implode("\n", [
|
||||
'x-archive-owner:anna@firma.de', 'x-archive-rcpt:' . implode(',', $rcpts), 'x-archive-from:x@extern.example',
|
||||
'x-archive-queue-id:4XyZ1', 'x-archive-received:2023-11-14T22:13:20.250Z', 'x-archive-direction:internal',
|
||||
'x-archive-source:smtp', 'x-archive-sha256:' . str_repeat('a', 64)]), 'key'), $block);
|
||||
}
|
||||
|
||||
public function testEmptyValuesAreLeftOutAndControlCharactersRemoved(): void
|
||||
{
|
||||
$journal = new Journal(["anna@firma.de\r\nX-Evil: 1"], ['a@b.example', ''], '', '', 'in', 'quarantine', 1700000000.0, '');
|
||||
$block = (new HeaderBuilder())->build(self::settings('archivecow'), $journal);
|
||||
|
||||
$this->assertStringNotContainsString("\r\nX-Evil", $block);
|
||||
$this->assertStringNotContainsString('X-Archive-From', $block);
|
||||
$this->assertStringNotContainsString('X-Archive-Queue-ID', $block);
|
||||
$this->assertStringNotContainsString('X-Archive-SHA256', $block);
|
||||
$this->assertStringContainsString("X-Archive-Owner: anna@firma.de X-Evil: 1\r\n", $block);
|
||||
$this->assertStringContainsString("X-Archive-Rcpt: a@b.example\r\n", $block);
|
||||
}
|
||||
|
||||
public function testEnvelopeAndNoneProfiles(): void
|
||||
{
|
||||
$settings = self::settings('envelope');
|
||||
$settings->headerFrom = 'X-Real-MailFrom';
|
||||
$settings->headerRcpt = 'X-Real-RcptTo';
|
||||
$this->assertSame("X-Real-MailFrom: x@extern.example\r\nX-Real-RcptTo: anna@firma.de,bcc@extern.example\r\n",
|
||||
(new HeaderBuilder())->build($settings, self::journal()));
|
||||
$this->assertSame("X-Real-MailFrom: <>\r\nX-Real-RcptTo: anna@firma.de,bcc@extern.example\r\n",
|
||||
(new HeaderBuilder())->build($settings, self::journal(from: '')));
|
||||
$this->assertSame('', (new HeaderBuilder())->build(self::settings('none'), self::journal()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\HeaderBuilder;
|
||||
use Mailcow\Archive\Service\JournalService;
|
||||
use Mailcow\Archive\Service\OwnerResolver;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use Mailcow\Tests\Support\FakeRedis;
|
||||
use Mailcow\Tests\Support\FakeSmtpClient;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class JournalServiceTest extends TestCase
|
||||
{
|
||||
private Settings $stored;
|
||||
private FakeArchiveLookup $lookup;
|
||||
private FakeRedis $redis;
|
||||
private FakeSmtpClient $smtp;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->stored = new Settings();
|
||||
$this->stored->enabled = true;
|
||||
$this->stored->address = 'journal@archive.example';
|
||||
$this->stored->signingKey = 'key';
|
||||
$this->lookup = new FakeArchiveLookup();
|
||||
$this->lookup->mailboxes = ['anna@firma.de' => ['inherit', 1], 'olga@other.de' => ['inherit', 0]];
|
||||
foreach (array_keys($this->lookup->mailboxes) as $username) {
|
||||
$this->lookup->aliases[$username] = $username;
|
||||
}
|
||||
$this->redis = new FakeRedis();
|
||||
$this->smtp = new FakeSmtpClient();
|
||||
}
|
||||
|
||||
private function service(?Context $context = null): JournalService
|
||||
{
|
||||
$repo = $this->createStub(SettingsRepository::class);
|
||||
$repo->method('load')->willReturnCallback(fn(): Settings => clone $this->stored);
|
||||
return new JournalService($repo, new OwnerResolver($this->lookup), new HeaderBuilder(), $this->smtp,
|
||||
new ArchiveState($this->redis), $context ?? Context::system());
|
||||
}
|
||||
|
||||
private function expectUserError(string|array $msg, callable $call): void
|
||||
{
|
||||
try {
|
||||
$call();
|
||||
$this->fail('UserError ' . json_encode($msg) . ' expected');
|
||||
} catch (UserError $e) {
|
||||
$this->assertSame($msg, $e->msg());
|
||||
}
|
||||
}
|
||||
|
||||
/** Header block and mail of a submitted copy, split after X-Archive-Signature */
|
||||
private static function split(string $data): array
|
||||
{
|
||||
$end = strpos($data, "\r\n", (int)strpos($data, 'X-Archive-Signature: ')) + 2;
|
||||
return [substr($data, 0, $end), substr($data, $end)];
|
||||
}
|
||||
|
||||
public function testPrepareForAnArchivedRecipient(): void
|
||||
{
|
||||
$sha256 = hash('sha256', 'mail');
|
||||
$result = $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de', 'olga@other.de'], 'user' => '',
|
||||
'bcc' => true, 'qid' => '4XyZ1', 'received' => 1700000000.25, 'sha256' => $sha256]);
|
||||
|
||||
$this->assertSame(['archive' => true, 'rcpt' => 'journal@archive.example', 'sender' => 'archive-bounce@localhost'],
|
||||
array_slice($result, 0, 3));
|
||||
$this->assertStringStartsWith("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de,olga@other.de\r\n"
|
||||
. "X-Archive-From: x@extern.example\r\nX-Archive-Queue-ID: 4XyZ1\r\nX-Archive-Received: 2023-11-14T22:13:20.250Z\r\n"
|
||||
. "X-Archive-Direction: in\r\nX-Archive-Source: smtp\r\nX-Archive-SHA256: $sha256\r\nX-Archive-Signature: ", $result['headers']);
|
||||
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||
}
|
||||
|
||||
public function testPrepareWithoutCopy(): void
|
||||
{
|
||||
$this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['olga@other.de']]));
|
||||
|
||||
$this->stored->signingKey = '';
|
||||
$this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de']]));
|
||||
|
||||
$this->stored->enabled = false;
|
||||
$this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de']]));
|
||||
$this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||
$this->assertSame([], $this->smtp->submitted);
|
||||
}
|
||||
|
||||
public function testPrepareCleansTheRequest(): void
|
||||
{
|
||||
$result = $this->service()->prepare(['from' => "x@extern.example\r\nBcc: y@extern.example", 'rcpt' => ['anna@firma.de', ['x'], 5],
|
||||
'qid' => str_repeat('Q', 100), 'received' => 'yesterday', 'sha256' => 'ABC']);
|
||||
|
||||
$this->assertTrue($result['archive']);
|
||||
$this->assertStringContainsString("\r\nX-Archive-From: x@extern.example Bcc: y@extern.example\r\n", $result['headers']);
|
||||
$this->assertStringContainsString("\r\nX-Archive-Rcpt: anna@firma.de\r\n", $result['headers']);
|
||||
$this->assertStringContainsString("\r\nX-Archive-Queue-ID: " . str_repeat('Q', 64) . "\r\n", $result['headers']);
|
||||
$this->assertMatchesRegularExpression('/\r\nX-Archive-Received: \d{4}-\d\d-\d\dT[\d:.]+Z\r\n/', $result['headers']);
|
||||
$this->assertStringNotContainsString('X-Archive-SHA256', $result['headers']);
|
||||
}
|
||||
|
||||
public function testCopyOfADeliveredMail(): void
|
||||
{
|
||||
$this->assertTrue($this->service()->submitDelivered("Subject: hi\n\n.body\n", 'x@extern.example', ['anna@firma.de'], 'quarantine'));
|
||||
|
||||
$sent = $this->smtp->submitted[0];
|
||||
$this->assertSame(['postfix', 591, 'archive-bounce@localhost', 'journal@archive.example'],
|
||||
[$sent['host'], $sent['port'], $sent['from'], $sent['rcpt']]);
|
||||
[$headers, $message] = self::split($sent['data']);
|
||||
$this->assertSame("Subject: hi\r\n\r\n.body\r\n", $message);
|
||||
$this->assertStringStartsWith("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de\r\n", $headers);
|
||||
$this->assertStringContainsString("\r\nX-Archive-Source: quarantine\r\nX-Archive-SHA256: " . hash('sha256', $message) . "\r\n", $headers);
|
||||
}
|
||||
|
||||
public function testCopyOfADeliveredMailNeverThrows(): void
|
||||
{
|
||||
$this->assertFalse($this->service()->submitDelivered("x\r\n", 'x@extern.example', ['olga@other.de'], 'massmail'));
|
||||
$this->assertSame([], $this->smtp->submitted);
|
||||
|
||||
$this->smtp->error = new SmtpException('connect', 'Connection refused');
|
||||
$errorLog = ini_set('error_log', '/dev/null');
|
||||
try {
|
||||
$this->assertFalse($this->service()->submitDelivered("Message-ID: <mm1@firma.de>\r\nSubject: x\r\n\r\nx\r\n",
|
||||
'x@extern.example', ['anna@firma.de'], 'massmail'));
|
||||
} finally {
|
||||
ini_set('error_log', (string)$errorLog);
|
||||
}
|
||||
$this->assertSame('1', $this->redis->strings['ARCHIVE_DROPPED']);
|
||||
// the Message-ID finds the mail in the archive log
|
||||
$this->assertSame(['qid' => '', 'reason' => 'massmail: connect: Connection refused', 'message_id' => 'mm1@firma.de'],
|
||||
array_slice(json_decode($this->redis->lists['ARCHIVE_DROPPED_LOG'][0], true), 1));
|
||||
}
|
||||
|
||||
public function testTestMail(): void
|
||||
{
|
||||
$this->stored->enabled = false;
|
||||
|
||||
$this->assertSame('Q1', $this->service(FakeContext::admin())->testMail());
|
||||
|
||||
[$headers, $message] = self::split($this->smtp->submitted[0]['data']);
|
||||
$this->assertStringStartsWith("X-Archive-Rcpt: journal@archive.example\r\nX-Archive-From: archive-bounce@localhost\r\n", $headers);
|
||||
$this->assertStringContainsString("\r\nX-Archive-Source: test\r\n", $headers);
|
||||
$this->assertStringContainsString("\r\nSubject: mailcow archive test\r\n", $message);
|
||||
}
|
||||
|
||||
public function testTestMailErrors(): void
|
||||
{
|
||||
$this->expectUserError('access_denied', fn() => $this->service(new Context('admin', 'API', [], false, 'ro'))->testMail());
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->testMail());
|
||||
|
||||
$this->smtp->error = new SmtpException('recipient', '550 5.7.1 <journal@archive.example>: relay denied');
|
||||
$this->expectUserError(['archive_testmail_failed', 'recipient: 550 5.7.1 journal@archive.example: relay denied'],
|
||||
fn() => $this->service(FakeContext::admin())->testMail());
|
||||
|
||||
$this->stored->address = '';
|
||||
$this->expectUserError('archive_incomplete', fn() => $this->service(FakeContext::admin())->testMail());
|
||||
$this->assertSame([], $this->smtp->submitted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\MailboxListService;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class MailboxListServiceTest extends TestCase
|
||||
{
|
||||
private FakeArchiveLookup $lookup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->lookup = new FakeArchiveLookup();
|
||||
// username => [mailbox setting, domain default]
|
||||
$this->lookup->mailboxes = [
|
||||
'own@firma.de' => ['1', 0],
|
||||
'inherit@firma.de' => ['inherit', 1],
|
||||
'excluded@firma.de' => ['0', 1],
|
||||
'plain@other.example' => ['inherit', 0],
|
||||
];
|
||||
}
|
||||
|
||||
private function service(bool $enabled, ?Context $context = null): MailboxListService
|
||||
{
|
||||
$settings = new Settings();
|
||||
$settings->enabled = $enabled;
|
||||
$repo = $this->createStub(SettingsRepository::class);
|
||||
$repo->method('load')->willReturn($settings);
|
||||
return new MailboxListService($this->lookup, $repo, $context ?? FakeContext::admin());
|
||||
}
|
||||
|
||||
public function testArchivedMailboxesWithTheirSource(): void
|
||||
{
|
||||
$expected = ['enabled' => 1, 'mailboxes' => [
|
||||
['username' => 'own@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'mailbox'],
|
||||
['username' => 'inherit@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'domain'],
|
||||
]];
|
||||
|
||||
$this->assertSame($expected, $this->service(true)->archived());
|
||||
// read-only API keys too: archivecow keeps its mailboxes in line with this list
|
||||
$this->assertSame($expected, $this->service(true, new Context('admin', 'API', [], false, 'ro'))->archived());
|
||||
}
|
||||
|
||||
public function testEmptyWhileArchivingIsOff(): void
|
||||
{
|
||||
$this->assertSame(['enabled' => 0, 'mailboxes' => []], $this->service(false)->archived());
|
||||
}
|
||||
|
||||
public function testOnlyAdmins(): void
|
||||
{
|
||||
foreach ([FakeContext::domainAdmin(['own@firma.de'], ['firma.de'], ['archive' => '1']), FakeContext::user('own@firma.de')] as $context) {
|
||||
try {
|
||||
$this->service(true, $context)->archived();
|
||||
$this->fail('AccessDenied expected');
|
||||
} catch (AccessDenied) {
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Service\OwnerResolver;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class OwnerResolverTest extends TestCase
|
||||
{
|
||||
private FakeArchiveLookup $lookup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->lookup = new FakeArchiveLookup();
|
||||
// archived domain firma.de, domain without archiving other.de
|
||||
$this->lookup->mailboxes = [
|
||||
'anna@firma.de' => ['inherit', 1],
|
||||
'bernd@firma.de' => ['inherit', 1],
|
||||
'buchhaltung@firma.de' => ['inherit', 1],
|
||||
'br@firma.de' => ['0', 1],
|
||||
'olga@other.de' => ['inherit', 0],
|
||||
'otto@other.de' => ['1', 0],
|
||||
];
|
||||
// mailboxes have alias self rows
|
||||
foreach (array_keys($this->lookup->mailboxes) as $username) {
|
||||
$this->lookup->aliases[$username] = $username;
|
||||
}
|
||||
}
|
||||
|
||||
private function owners(array $rcpts, string $from = 'sender@extern.example', string $user = '', bool $bcc = true): array
|
||||
{
|
||||
return (new OwnerResolver($this->lookup))->resolve($from, $rcpts, $user, $bcc)['owners'];
|
||||
}
|
||||
|
||||
public static function incoming(): array
|
||||
{
|
||||
return [
|
||||
'mailbox' => [['anna@firma.de'], ['anna@firma.de']],
|
||||
'upper case and brackets' => [['<Anna@FIRMA.de>'], ['anna@firma.de']],
|
||||
'tag' => [['anna+news@firma.de'], ['anna@firma.de']],
|
||||
'external' => [['someone@extern.example'], []],
|
||||
'mailbox setting off' => [['br@firma.de'], []],
|
||||
'domain without archiving' => [['olga@other.de'], []],
|
||||
'mailbox setting on in a domain without archiving' => [['otto@other.de'], ['otto@other.de']],
|
||||
'several recipients, sorted and unique' => [['bernd@firma.de', 'anna@firma.de', 'anna+x@firma.de'], ['anna@firma.de', 'bernd@firma.de']],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('incoming')]
|
||||
public function testIncomingRecipients(array $rcpts, array $owners): void
|
||||
{
|
||||
$this->assertSame($owners, $this->owners($rcpts));
|
||||
}
|
||||
|
||||
public function testAliasExpandsAllBranches(): void
|
||||
{
|
||||
$this->lookup->aliases['team@firma.de'] = 'sales@firma.de,support@firma.de';
|
||||
$this->lookup->aliases['sales@firma.de'] = 'anna@firma.de';
|
||||
$this->lookup->aliases['support@firma.de'] = 'bernd@firma.de,someone@extern.example';
|
||||
|
||||
$this->assertSame(['anna@firma.de', 'bernd@firma.de'], $this->owners(['team@firma.de']));
|
||||
}
|
||||
|
||||
public function testAliasKeepingACopyAndLoops(): void
|
||||
{
|
||||
$this->lookup->aliases['anna@firma.de'] = 'anna@firma.de,bernd@firma.de';
|
||||
$this->lookup->aliases['loop1@firma.de'] = 'loop2@firma.de';
|
||||
$this->lookup->aliases['loop2@firma.de'] = 'loop1@firma.de,olga@other.de,otto@other.de';
|
||||
|
||||
$this->assertSame(['anna@firma.de', 'bernd@firma.de'], $this->owners(['anna@firma.de']));
|
||||
$this->assertSame(['otto@other.de'], $this->owners(['loop1@firma.de']));
|
||||
}
|
||||
|
||||
public function testCatchAllOnlyForAddressesWithoutOwnEntry(): void
|
||||
{
|
||||
$this->lookup->aliases['@firma.de'] = 'buchhaltung@firma.de';
|
||||
$this->lookup->aliases['old@firma.de'] = 'gone@firma.de';
|
||||
|
||||
$this->assertSame(['buchhaltung@firma.de'], $this->owners(['unknown@firma.de']));
|
||||
$this->assertSame(['anna@firma.de'], $this->owners(['anna@firma.de']));
|
||||
// a target without entry of its own gets the catch-all too, like Postfix
|
||||
$this->assertSame(['buchhaltung@firma.de'], $this->owners(['old@firma.de']));
|
||||
}
|
||||
|
||||
public function testAliasDomain(): void
|
||||
{
|
||||
$this->lookup->aliasDomains['firma.com'] = 'firma.de';
|
||||
$this->lookup->aliases['@firma.com'] = 'bernd@firma.de';
|
||||
|
||||
$this->assertSame(['anna@firma.de'], $this->owners(['anna@firma.com']));
|
||||
$this->assertSame(['anna@firma.de'], $this->owners(['anna+tag@firma.com']));
|
||||
$this->assertSame(['bernd@firma.de'], $this->owners(['nobody@firma.com']));
|
||||
}
|
||||
|
||||
public function testRecipientMapsRewriteFirst(): void
|
||||
{
|
||||
$this->lookup->recipientMaps['anna@extern.example'] = 'anna@firma.de';
|
||||
$this->lookup->recipientMaps['@alt.example'] = '@firma.de';
|
||||
|
||||
$this->assertSame(['anna@firma.de'], $this->owners(['anna@extern.example']));
|
||||
$this->assertSame(['bernd@firma.de'], $this->owners(['bernd@alt.example']));
|
||||
}
|
||||
|
||||
public function testSpamAliasResourceAndLocalTargets(): void
|
||||
{
|
||||
$this->lookup->spamAliases['x7k2@firma.de'] = 'anna@firma.de';
|
||||
$this->lookup->resources = ['room@firma.de'];
|
||||
$this->lookup->aliases['@firma.de'] = 'bernd@firma.de';
|
||||
$this->lookup->aliases['learn@firma.de'] = 'spam@localhost';
|
||||
|
||||
$this->assertSame(['anna@firma.de'], $this->owners(['x7k2@firma.de']));
|
||||
$this->assertSame([], $this->owners(['room@firma.de']));
|
||||
$this->assertSame([], $this->owners(['learn@firma.de']));
|
||||
}
|
||||
|
||||
public function testBccDestinationsOnlyWhenRspamdSentTheCopy(): void
|
||||
{
|
||||
$this->lookup->bcc['rcpt']['olga@other.de'] = 'buchhaltung@firma.de';
|
||||
$this->lookup->bcc['sender']['@extern.example'] = 'bernd@firma.de';
|
||||
|
||||
$this->assertSame(['bernd@firma.de', 'buchhaltung@firma.de'], $this->owners(['olga+tag@other.de']));
|
||||
$this->assertSame([], $this->owners(['olga@other.de'], bcc: false));
|
||||
}
|
||||
|
||||
public function testOutgoingSaslUserAndSentAsMailbox(): void
|
||||
{
|
||||
$this->lookup->aliases['info@firma.de'] = 'anna@firma.de,bernd@firma.de';
|
||||
$this->lookup->aliasDomains['firma.com'] = 'firma.de';
|
||||
$ext = ['someone@extern.example'];
|
||||
|
||||
$this->assertSame(['anna@firma.de'], $this->owners($ext, 'anna@firma.de', 'anna@firma.de'));
|
||||
$this->assertSame(['anna@firma.de', 'buchhaltung@firma.de'], $this->owners($ext, 'buchhaltung@firma.de', 'anna@firma.de'));
|
||||
$this->assertSame(['anna@firma.de', 'buchhaltung@firma.de'], $this->owners($ext, 'buchhaltung+x@firma.com', 'Anna@firma.de'));
|
||||
// an alias as sender is no mailbox
|
||||
$this->assertSame(['anna@firma.de'], $this->owners($ext, 'info@firma.de', 'anna@firma.de'));
|
||||
// the sent-as mailbox has archiving off
|
||||
$this->assertSame(['anna@firma.de'], $this->owners($ext, 'br@firma.de', 'anna@firma.de'));
|
||||
}
|
||||
|
||||
public function testSenderWithoutLoginNeverOwns(): void
|
||||
{
|
||||
$this->assertSame([], $this->owners(['someone@extern.example'], 'anna@firma.de'));
|
||||
$this->assertSame([], $this->owners(['someone@extern.example'], 'anna@firma.de', 'x1@mailcow.local'));
|
||||
}
|
||||
|
||||
public function testDirection(): void
|
||||
{
|
||||
$resolver = new OwnerResolver($this->lookup);
|
||||
|
||||
$this->assertSame('in', $resolver->resolve('x@extern.example', ['anna@firma.de'], '', false)['direction']);
|
||||
$this->assertSame('out', $resolver->resolve('anna@firma.de', ['x@extern.example'], 'anna@firma.de', false)['direction']);
|
||||
$this->assertSame('internal', $resolver->resolve('anna@firma.de', ['olga@other.de'], 'anna@firma.de', false)['direction']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\SettingsService;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use Mailcow\Tests\Support\FakeRedis;
|
||||
use Mailcow\Tests\Support\FakeSmtpClient;
|
||||
use PDOException;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class SettingsServiceTest extends TestCase
|
||||
{
|
||||
private SettingsRepository&MockObject $repo;
|
||||
private Settings $stored;
|
||||
private FakeRedis $redis;
|
||||
private FakeSmtpClient $smtp;
|
||||
private FakeArchiveLookup $lookup;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->stored = new Settings();
|
||||
$this->stored->address = 'journal@archive.example';
|
||||
$this->stored->password = 'old-password';
|
||||
$this->stored->signingKey = 'old-key';
|
||||
$this->repo = $this->createMock(SettingsRepository::class);
|
||||
$this->repo->method('load')->willReturnCallback(fn(): Settings => clone $this->stored);
|
||||
$this->redis = new FakeRedis();
|
||||
$this->smtp = new FakeSmtpClient();
|
||||
$this->lookup = new FakeArchiveLookup();
|
||||
$this->lookup->domains = ['firma.de', 'alias-firma.de'];
|
||||
}
|
||||
|
||||
private function service(?Context $context = null): SettingsService
|
||||
{
|
||||
return new SettingsService($this->repo, $this->lookup, new ArchiveState($this->redis), $this->smtp, $context ?? FakeContext::admin());
|
||||
}
|
||||
|
||||
private function expectUserError(string|array $msg, callable $call): void
|
||||
{
|
||||
try {
|
||||
$call();
|
||||
$this->fail('UserError ' . json_encode($msg) . ' expected');
|
||||
} catch (UserError $e) {
|
||||
$this->assertSame($msg, $e->msg());
|
||||
}
|
||||
}
|
||||
|
||||
public function testSaveKeepsMissingValuesAndEmptySecrets(): void
|
||||
{
|
||||
$this->repo->expects($this->once())->method('save')->with($this->callback(fn(Settings $s): bool => $s->enabled
|
||||
&& $s->address === 'journal@archive.example' && $s->password === 'old-password' && $s->signingKey === 'old-key'));
|
||||
|
||||
$settings = $this->service()->save(['enabled' => ['0', '1'], 'password' => '', 'signing_secret' => '']);
|
||||
|
||||
$this->assertSame(1, $settings->toArray()['enabled']);
|
||||
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||
}
|
||||
|
||||
public function testSaveRemovesThePassword(): void
|
||||
{
|
||||
$this->assertSame('old-password', $this->service()->save(['password' => '', 'password_remove' => '0'])->password);
|
||||
$this->assertSame('', $this->service()->save(['password' => '', 'password_remove' => '1'])->password);
|
||||
}
|
||||
|
||||
public function testEnabledIsOffWithoutTheTable(): void
|
||||
{
|
||||
$repo = $this->createMock(SettingsRepository::class);
|
||||
$repo->method('load')->willThrowException(new PDOException('no such table'));
|
||||
|
||||
$this->assertFalse((new SettingsService($repo, $this->lookup, new ArchiveState($this->redis), $this->smtp, FakeContext::admin()))->enabled());
|
||||
}
|
||||
|
||||
public function testAddressOutsideTheMailcowDomains(): void
|
||||
{
|
||||
$this->expectUserError('archive_address_local', fn() => $this->service()->save(['address' => 'journal@firma.de']));
|
||||
$this->expectUserError('archive_address_local', fn() => $this->service()->save(['address' => 'Journal@Alias-Firma.de']));
|
||||
$this->assertSame('journal@archiv.firma.de', $this->service()->save(['address' => 'journal@archiv.firma.de'])->address);
|
||||
}
|
||||
|
||||
public function testSaveAllFields(): void
|
||||
{
|
||||
$settings = $this->service()->save(['enabled' => '0', 'address' => ' Journal@Archive.Example ', 'host' => '[2001:DB8::25]',
|
||||
'port' => '2525', 'tls' => 'verify', 'username' => 'mailcow', 'password' => 'new:password', 'profile' => 'envelope',
|
||||
'signing_secret' => 'new-key', 'header_from' => 'X-Real-MailFrom', 'header_rcpt' => 'X-Real-RcptTo']);
|
||||
|
||||
$this->assertSame(['enabled' => 0, 'address' => 'journal@archive.example', 'host' => '2001:db8::25', 'port' => 2525,
|
||||
'tls' => 'verify', 'username' => 'mailcow', 'has_password' => 1, 'profile' => 'envelope', 'has_signing_secret' => 1,
|
||||
'header_from' => 'X-Real-MailFrom', 'header_rcpt' => 'X-Real-RcptTo'], $settings->toArray());
|
||||
$this->assertSame(['new:password', 'new-key'], [$settings->password, $settings->signingKey]);
|
||||
$this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||
}
|
||||
|
||||
public static function invalid(): array
|
||||
{
|
||||
return [
|
||||
'address' => [['address' => 'journal'], 'archive_address_invalid'],
|
||||
'address array' => [['address' => ['x']], 'archive_address_invalid'],
|
||||
'host' => [['host' => 'archive example'], 'archive_host_invalid'],
|
||||
'port' => [['port' => '0'], 'archive_port_invalid'],
|
||||
'port too high' => [['port' => '65536'], 'archive_port_invalid'],
|
||||
'tls' => [['tls' => 'none'], 'archive_tls_invalid'],
|
||||
'username with colon' => [['username' => 'a:b'], 'archive_username_invalid'],
|
||||
'password with space' => [['password' => 'a b'], 'archive_password_invalid'],
|
||||
'password too long' => [['password' => str_repeat('a', 256)], 'archive_password_invalid'],
|
||||
'profile' => [['profile' => 'journal'], 'archive_profile_invalid'],
|
||||
'signing secret not ascii' => [['signing_secret' => 'schlüssel'], 'archive_signing_secret_invalid'],
|
||||
'header name with space' => [['header_from' => 'X Envelope'], 'archive_header_invalid'],
|
||||
'header name with line break' => [['header_rcpt' => "X-To\r\nBcc"], 'archive_header_invalid'],
|
||||
'switch on without address' => [['enabled' => '1', 'address' => ''], 'archive_incomplete'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalid')]
|
||||
public function testSaveValidates(array $input, string $error): void
|
||||
{
|
||||
$this->repo->expects($this->never())->method('save');
|
||||
$this->expectUserError($error, fn() => $this->service()->save($input));
|
||||
$this->assertArrayNotHasKey('ARCHIVE_ENABLED', $this->redis->strings);
|
||||
}
|
||||
|
||||
public function testArchivecowNeedsASigningKey(): void
|
||||
{
|
||||
$this->stored->signingKey = '';
|
||||
$this->expectUserError('archive_incomplete', fn() => $this->service()->save(['enabled' => '1']));
|
||||
|
||||
$this->assertTrue($this->service()->save(['enabled' => '1', 'profile' => 'none'])->enabled);
|
||||
}
|
||||
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$readOnly = new Context('admin', 'API', [], false, 'ro');
|
||||
$this->repo->expects($this->never())->method('save');
|
||||
|
||||
$this->assertSame(1, $this->service($readOnly)->get()->toArray()['has_password']);
|
||||
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->save(['enabled' => '0']));
|
||||
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->test());
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->get());
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user())->save([]));
|
||||
$this->assertSame([], $this->smtp->probed);
|
||||
}
|
||||
|
||||
public function testConnectionTestWithTheStoredTarget(): void
|
||||
{
|
||||
$this->stored->host = 'smtp.archive.example';
|
||||
$this->stored->port = 587;
|
||||
$this->stored->tls = 'verify';
|
||||
$this->stored->username = 'mailcow';
|
||||
|
||||
$this->assertSame('archive_test_ok', $this->service()->test());
|
||||
$this->assertSame([['host' => 'smtp.archive.example', 'port' => 587, 'tls' => 'verify', 'username' => 'mailcow',
|
||||
'password' => 'old-password', 'from' => 'archive-bounce@localhost', 'rcpt' => 'journal@archive.example']], $this->smtp->probed);
|
||||
}
|
||||
|
||||
public function testConnectionTestReportsMissingExtensions(): void
|
||||
{
|
||||
$this->smtp->mx['archive.example'] = 'mx1.archive.example';
|
||||
$this->smtp->extensions = ['STARTTLS', 'SIZE'];
|
||||
|
||||
$this->assertSame(['archive_test_ok_missing', '8BITMIME'], $this->service()->test());
|
||||
$this->assertSame('mx1.archive.example', $this->smtp->probed[0]['host']);
|
||||
}
|
||||
|
||||
public function testConnectionTestFailure(): void
|
||||
{
|
||||
$this->smtp->error = new SmtpException('recipient', "550 5.1.1 <journal@archive.example>:\n unknown");
|
||||
|
||||
$this->expectUserError(['archive_test_failed', 'archive.example:25 recipient: 550 5.1.1 journal@archive.example: unknown'],
|
||||
fn() => $this->service()->test());
|
||||
}
|
||||
|
||||
public function testConnectionTestNeedsAnAddress(): void
|
||||
{
|
||||
$this->stored->address = '';
|
||||
$this->expectUserError('archive_incomplete', fn() => $this->service()->test());
|
||||
$this->assertSame([], $this->smtp->probed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Service;
|
||||
|
||||
use Mailcow\Archive\Legacy\LegacyQueue;
|
||||
use Mailcow\Archive\Model\Settings;
|
||||
use Mailcow\Archive\Redis\ArchiveState;
|
||||
use Mailcow\Archive\Repository\SettingsRepository;
|
||||
use Mailcow\Archive\Service\StatusService;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use Mailcow\Tests\Support\FakeRedis;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class StatusServiceTest extends TestCase
|
||||
{
|
||||
private const REFUSED = 'connect to archive.example[192.0.2.5]:25: Connection refused';
|
||||
|
||||
private FakeRedis $redis;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->redis = new FakeRedis();
|
||||
}
|
||||
|
||||
private function service(?Context $context = null): StatusService
|
||||
{
|
||||
$settings = new Settings();
|
||||
$settings->enabled = true;
|
||||
$settings->address = 'journal@archive.example';
|
||||
$settings->profile = 'none';
|
||||
$repo = $this->createStub(SettingsRepository::class);
|
||||
$repo->method('load')->willReturn($settings);
|
||||
$queue = $this->createMock(LegacyQueue::class);
|
||||
$queue->method('entries')->with('archive-bounce@localhost')->willReturn([
|
||||
['queue_id' => 'A1', 'arrival_time' => 1700000100, 'message_size' => 2048, 'recipients' => ['journal@archive.example (' . self::REFUSED . ')']],
|
||||
['queue_id' => 'A2', 'arrival_time' => 1700000000, 'message_size' => 1024, 'recipients' => ['journal@archive.example (' . self::REFUSED . ')']],
|
||||
['queue_id' => 'A3', 'arrival_time' => 1700000200, 'message_size' => 10, 'recipients' => ['journal@archive.example']],
|
||||
]);
|
||||
return new StatusService($repo, new ArchiveState($this->redis), $queue, $context ?? new Context('admin', 'API', [], false, 'ro'));
|
||||
}
|
||||
|
||||
/** POSTFIX_MAILLOG line as syslog-ng writes it */
|
||||
private static function log(int $time, string $program, string $message): string
|
||||
{
|
||||
return json_encode(['time' => (string)$time, 'program' => $program, 'priority' => 'info', 'message' => $message]);
|
||||
}
|
||||
|
||||
public function testStatus(): void
|
||||
{
|
||||
$state = new ArchiveState($this->redis);
|
||||
// a day old: no longer a problem, still counted
|
||||
$this->redis->lPush('ARCHIVE_DROPPED_LOG', json_encode(['time' => time() - 90000, 'qid' => 'OLD1', 'reason' => 'old', 'message_id' => '']));
|
||||
$state->dropped('4XyZ1', 'owner lookup failed', 'm1@firma.de');
|
||||
$this->redis->lists['POSTFIX_MAILLOG'] = [
|
||||
self::log(1700000300, 'postfix/archive/smtp', 'A4: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, delay=0.2, '
|
||||
. 'delays=0.1/0/0.05/0.05, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 77)'),
|
||||
self::log(1700000280, 'postfix/archive/smtp', 'A1: to=<journal@archive.example>, relay=none, delay=180, dsn=4.4.1, status=deferred (' . self::REFUSED . ')'),
|
||||
self::log(1700000200, 'postfix/archive/smtp', 'A0: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=2.0.0, status=sent (250 ok)'),
|
||||
];
|
||||
|
||||
$this->assertSame([
|
||||
'enabled' => 1, 'configured' => 1, 'queued' => 3, 'queued_bytes' => 3082, 'oldest_queued' => 1700000000,
|
||||
'delay_reasons' => [self::REFUSED], 'last_delivery' => 1700000300, 'dropped' => 1, 'dropped_24h' => 1,
|
||||
'last_dropped' => ['time' => $this->redis->lists['ARCHIVE_DROPPED_LOG'] ? json_decode($this->redis->lists['ARCHIVE_DROPPED_LOG'][0], true)['time'] : 0,
|
||||
'reason' => 'owner lookup failed'],
|
||||
], $this->service()->status());
|
||||
}
|
||||
|
||||
public function testLogWithMessageIds(): void
|
||||
{
|
||||
(new ArchiveState($this->redis))->dropped('4XyZ1', 'owner lookup failed', 'm1@firma.de');
|
||||
$this->redis->lists['POSTFIX_MAILLOG'] = [
|
||||
self::log(1700000310, 'postfix/qmgr', 'E1: from=<archive-bounce@localhost>, status=expired, returned to sender'),
|
||||
self::log(1700000300, 'postfix/archive/smtp', 'A4: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=2.0.0, '
|
||||
. 'status=sent (250 2.0.0 Ok: queued as 77)'),
|
||||
self::log(1700000295, 'postfix/qmgr', 'Q9: from=<someone@firma.de>, status=expired, returned to sender'),
|
||||
self::log(1700000290, 'postfix/smtp', 'B1: to=<x@extern.example>, relay=mx.extern.example[198.51.100.7]:25, dsn=2.0.0, status=sent (250 Ok)'),
|
||||
self::log(1700000285, 'postfix/archive/discard', 'D1: to=<archive-bounce@localhost>, relay=none, dsn=2.0.0, status=sent (localhost)'),
|
||||
self::log(1700000280, 'postfix/archive/smtp', 'A2: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=4.1.1, '
|
||||
. 'status=SOFTBOUNCE (host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command))'),
|
||||
self::log(1700000270, 'postfix/archive/smtp', 'Untrusted TLS connection established to archive.example[192.0.2.5]:25'),
|
||||
self::log(1700000262, 'postfix/cleanup', 'A4: message-id=<m4@extern.example>'),
|
||||
self::log(1700000261, 'postfix/cleanup', 'A2: message-id=<m2@firma.de>'),
|
||||
'not json',
|
||||
];
|
||||
|
||||
$log = $this->service()->log();
|
||||
|
||||
// the mail not archived just now is the newest entry
|
||||
$this->assertSame(['status' => 'dropped', 'qid' => '4XyZ1', 'message_id' => 'm1@firma.de', 'text' => 'owner lookup failed'],
|
||||
array_diff_key($log[0], ['time' => 0]));
|
||||
$this->assertSame([
|
||||
['time' => 1700000310, 'status' => 'expired', 'qid' => 'E1', 'message_id' => '', 'text' => ''],
|
||||
['time' => 1700000300, 'status' => 'sent', 'qid' => 'A4', 'message_id' => 'm4@extern.example', 'text' => '250 2.0.0 Ok: queued as 77'],
|
||||
['time' => 1700000280, 'status' => 'softbounce', 'qid' => 'A2', 'message_id' => 'm2@firma.de',
|
||||
'text' => 'host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command)'],
|
||||
], array_slice($log, 1));
|
||||
$this->assertCount(2, $this->service()->log(2));
|
||||
}
|
||||
|
||||
public function testOnlyAdmins(): void
|
||||
{
|
||||
foreach (['status', 'log'] as $method) {
|
||||
try {
|
||||
$this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->$method();
|
||||
$this->fail('UserError expected');
|
||||
} catch (UserError) {
|
||||
$this->addToAssertionCount(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Archive\Smtp;
|
||||
|
||||
use Mailcow\Archive\Smtp\SmtpClient;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* SMTP dialog against scripted server replies.
|
||||
*/
|
||||
class SmtpClientTest extends TestCase
|
||||
{
|
||||
private static function client(array $replies): SmtpClient
|
||||
{
|
||||
return new class ($replies) extends SmtpClient {
|
||||
public array $written = [];
|
||||
public array $tls = [];
|
||||
public bool $closed = false;
|
||||
|
||||
public function __construct(private array $replies)
|
||||
{
|
||||
}
|
||||
|
||||
protected function open(string $host, int $port): void
|
||||
{
|
||||
$this->written[] = "OPEN $host:$port";
|
||||
}
|
||||
|
||||
protected function close(): void
|
||||
{
|
||||
$this->closed = true;
|
||||
}
|
||||
|
||||
protected function write(string $data): void
|
||||
{
|
||||
$this->written[] = $data;
|
||||
}
|
||||
|
||||
protected function readLine(): ?string
|
||||
{
|
||||
return array_shift($this->replies);
|
||||
}
|
||||
|
||||
protected function startTls(string $host, bool $verify): void
|
||||
{
|
||||
$this->tls[] = [$host, $verify];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public function testSubmitWithCrlfAndDotStuffing(): void
|
||||
{
|
||||
$client = self::client(['220 postfix ESMTP', '250-postfix', '250 8BITMIME', '250 2.1.0 Ok', '250 2.1.5 Ok',
|
||||
'354 End data with <CR><LF>.<CR><LF>', '250 2.0.0 Ok: queued as 4XyZ1']);
|
||||
|
||||
$qid = $client->submit('postfix', 591, 'archive-bounce@localhost', 'journal@archive.example', "X-A: 1\r\n\r\n.dot\nline\r\n");
|
||||
|
||||
$this->assertSame('4XyZ1', $qid);
|
||||
$this->assertSame(['OPEN postfix:591', 'EHLO ' . (gethostname() ?: 'mailcow') . "\r\n", "MAIL FROM:<archive-bounce@localhost>\r\n",
|
||||
"RCPT TO:<journal@archive.example>\r\n", "DATA\r\n", "X-A: 1\r\n\r\n..dot\r\nline\r\n.\r\n", "QUIT\r\n"], $client->written);
|
||||
$this->assertTrue($client->closed);
|
||||
}
|
||||
|
||||
public function testProbeWithStartTlsAndAuth(): void
|
||||
{
|
||||
$client = self::client(['220 archive ESMTP', '250-archive', '250-STARTTLS', '250 AUTH PLAIN', '220 2.0.0 Ready',
|
||||
'250-archive', '250-8BITMIME', '250-SMTPUTF8', '250 AUTH PLAIN', '235 2.7.0 Authenticated', '250 Ok', '250 Ok', '250 Ok']);
|
||||
|
||||
$extensions = $client->probe('archive.example', 25, 'verify', 'user', 'pw', 'archive-bounce@localhost', 'journal@archive.example');
|
||||
|
||||
$this->assertSame(['8BITMIME', 'SMTPUTF8', 'AUTH'], $extensions);
|
||||
$this->assertSame([['archive.example', true]], $client->tls);
|
||||
$this->assertContains('AUTH PLAIN ' . base64_encode("\0user\0pw") . "\r\n", $client->written);
|
||||
$this->assertContains("RSET\r\n", $client->written);
|
||||
}
|
||||
|
||||
public function testProbeNeedsStartTlsUnlessOptional(): void
|
||||
{
|
||||
try {
|
||||
self::client(['220 archive ESMTP', '250 archive'])->probe('archive.example', 25, 'encrypt', '', '', 'a@localhost', 'j@a.example');
|
||||
$this->fail('SmtpException expected');
|
||||
} catch (SmtpException $e) {
|
||||
$this->assertSame('tls: STARTTLS not offered', $e->describe());
|
||||
}
|
||||
$client = self::client(['220 archive ESMTP', '250 archive', '250 Ok', '250 Ok', '250 Ok']);
|
||||
$this->assertSame([], $client->probe('archive.example', 25, 'may', '', '', 'a@localhost', 'j@a.example'));
|
||||
$this->assertSame([], $client->tls);
|
||||
}
|
||||
|
||||
public function testRefusalsNameTheStep(): void
|
||||
{
|
||||
$cases = [
|
||||
'connect' => ['421 4.3.2 Service not available'],
|
||||
'auth' => ['220 archive', '250-archive', '250 STARTTLS', '220 Go', '250 archive', '535 5.7.8 failed'],
|
||||
'recipient' => ['220 archive', '250-archive', '250 STARTTLS', '220 Go', '250 archive', '235 Ok', '250 Ok', '550 5.1.1 <j@a.example>: unknown'],
|
||||
];
|
||||
foreach ($cases as $step => $replies) {
|
||||
try {
|
||||
self::client($replies)->probe('archive.example', 25, 'encrypt', 'user', 'pw', 'a@localhost', 'j@a.example');
|
||||
$this->fail("SmtpException at $step expected");
|
||||
} catch (SmtpException $e) {
|
||||
$this->assertSame($step, $e->step);
|
||||
$this->assertSame(end($replies), $e->reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testConnectionClosedDuringData(): void
|
||||
{
|
||||
$this->expectException(SmtpException::class);
|
||||
self::client(['220 postfix', '250 postfix', '250 Ok', '250 Ok', '354 go'])->submit('postfix', 591, 'a@localhost', 'j@a.example', "x\r\n");
|
||||
}
|
||||
|
||||
public function testCrlfAndDotStuffing(): void
|
||||
{
|
||||
$this->assertSame("a\r\nb\r\nc\r\n", SmtpClient::crlf("a\nb\rc"));
|
||||
$this->assertSame("a\r\n", SmtpClient::crlf("a\r\n"));
|
||||
$this->assertSame("..a\r\n..\r\n", SmtpClient::dotStuff(".a\r\n.\r\n"));
|
||||
}
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\MailPolicy\Service;
|
||||
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||
use Mailcow\Tests\Support\FakeMailPolicyLookup;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PolicyEngineTest extends TestCase
|
||||
{
|
||||
private FakeMailPolicyLookup $lookup;
|
||||
private PolicyEngine $engine;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->lookup = new FakeMailPolicyLookup();
|
||||
$this->lookup->domains = ['firma.de', 'tochter.de'];
|
||||
$this->lookup->aliasDomains = ['firma.com' => 'firma.de'];
|
||||
$this->lookup->mailboxes = [
|
||||
'azubi@firma.de' => 'firma.de',
|
||||
'kollege@firma.de' => 'firma.de',
|
||||
'chef@firma.de' => 'firma.de',
|
||||
'info@firma.de' => 'firma.de',
|
||||
'andere@tochter.de' => 'tochter.de',
|
||||
];
|
||||
$this->lookup->groups = [
|
||||
7 => ['address' => 'azubis@firma.de', 'members' => ['azubi@firma.de']],
|
||||
8 => ['address' => 'vorstand@firma.de', 'members' => ['chef@firma.de']],
|
||||
];
|
||||
$this->engine = new PolicyEngine($this->lookup);
|
||||
}
|
||||
|
||||
private static function policy(int $id, array $values): Policy
|
||||
{
|
||||
$policy = new Policy();
|
||||
$policy->id = $id;
|
||||
$policy->domain = 'firma.de';
|
||||
$policy->name = 'policy ' . $id;
|
||||
foreach ($values as $property => $value) {
|
||||
$policy->$property = $value;
|
||||
}
|
||||
return $policy;
|
||||
}
|
||||
|
||||
/** [allowed, side] of a check */
|
||||
private function check(string $sender, string $recipient, string $login = '', int $port = 587, string $helo = ''): array
|
||||
{
|
||||
$decision = $this->engine->check($sender, $recipient, $login, $port, $helo);
|
||||
return [$decision->allowed, $decision->side];
|
||||
}
|
||||
|
||||
public function testWithoutPoliciesEverythingPasses(): void
|
||||
{
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de'));
|
||||
$this->assertSame('DUNNO', $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de')->action());
|
||||
}
|
||||
|
||||
public function testSendOnlyInternalWithAllowedAndBlockedEntries(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(1, [
|
||||
'sendMode' => 'internal', 'sendAllow' => ['partner.de', 'kunde@extern.example'], 'sendBlock' => ['chef@firma.de'],
|
||||
])];
|
||||
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de'));
|
||||
// alias domain and sub-address are internal
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'Kollege+x@firma.com', 'azubi@firma.de'));
|
||||
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de'));
|
||||
// another domain of the same mailcow is not internal
|
||||
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'andere@tochter.de', 'azubi@firma.de'));
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'jemand@partner.de', 'azubi@firma.de'));
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'kunde@extern.example', 'azubi@firma.de'));
|
||||
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
|
||||
// the rules belong to the login: without it nothing is sent as azubi
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'x@extern.example'));
|
||||
}
|
||||
|
||||
public function testSendOnlyListedRecipientsThroughAGroup(): void
|
||||
{
|
||||
$this->lookup->groupPolicies[7] = [self::policy(2, ['sendMode' => 'list', 'sendAllow' => ['group:8']])];
|
||||
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'vorstand@firma.de', 'azubi@firma.de'));
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
|
||||
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de'));
|
||||
// not a member: no rule
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'x@extern.example', 'kollege@firma.de'));
|
||||
}
|
||||
|
||||
public function testSendingAsAnotherMailboxAppliesItsPolicyToo(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['info@firma.de'] = [self::policy(3, ['sendMode' => 'internal'])];
|
||||
|
||||
$this->assertSame([false, 'send'], $this->check('info@firma.de', 'x@extern.example', 'chef@firma.de'));
|
||||
$this->assertSame([true, ''], $this->check('chef@firma.de', 'x@extern.example', 'chef@firma.de'));
|
||||
}
|
||||
|
||||
public function testReceiveOnlyInternalCannotBeForged(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(4, ['receiveMode' => 'internal', 'receiveAllow' => ['partner.de'],
|
||||
'receiveBlock' => ['chef@firma.de']])];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'azubi@firma.de', '', 25));
|
||||
// a forged internal sender is external
|
||||
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'azubi@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'azubi+news@firma.com', 'kollege@firma.de'));
|
||||
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'azubi@firma.de', 'andere@tochter.de'));
|
||||
// an external domain on the list is compared with the envelope sender
|
||||
$this->assertSame([true, ''], $this->check('jemand@partner.de', 'azubi@firma.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('chef@firma.de', 'azubi@firma.de', 'chef@firma.de'));
|
||||
// null sender (bounces) is external
|
||||
$this->assertSame([false, 'receive'], $this->check('', 'azubi@firma.de', '', 25));
|
||||
}
|
||||
|
||||
public function testListedLocalSendersMustBeLoggedIn(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(5, ['receiveMode' => 'list',
|
||||
'receiveAllow' => ['kollege@firma.de', 'group:7', 'tochter.de']])];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'chef@firma.de', 'kollege@firma.de'));
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
|
||||
$this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('andere@tochter.de', 'chef@firma.de', 'andere@tochter.de'));
|
||||
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'chef@firma.de', '', 25));
|
||||
}
|
||||
|
||||
public function testGroupAddressAndMembersAsRecipients(): void
|
||||
{
|
||||
$this->lookup->groupPolicies[8] = [self::policy(6, ['receiveMode' => 'internal', 'receiveBlock' => ['group:7']])];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'vorstand@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'vorstand@firma.de', 'kollege@firma.de'));
|
||||
// the policy of a group applies to its members as well
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de'));
|
||||
}
|
||||
|
||||
public function testInternalFlagOfAliasesAndGroups(): void
|
||||
{
|
||||
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team@firma.de', '', 25));
|
||||
// the old rspamd rule let forged senders of the domain pass
|
||||
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.com', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', 'kollege@firma.de'));
|
||||
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'team@firma.de', 'andere@tochter.de'));
|
||||
}
|
||||
|
||||
public function testTheStrictestPolicyWins(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(9, ['sendMode' => 'all'])];
|
||||
$this->lookup->groupPolicies[7] = [self::policy(10, ['sendMode' => 'internal', 'rejectText' => 'Azubis senden nur intern'])];
|
||||
|
||||
$decision = $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de', 587);
|
||||
$this->assertSame(['send', 10, '550 5.7.1 Azubis senden nur intern'], [$decision->side, $decision->policy->id, $decision->action()]);
|
||||
}
|
||||
|
||||
public function testDefaultTextsAndOwnDeliveries(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(11, ['sendMode' => 'internal', 'receiveMode' => 'internal'])];
|
||||
|
||||
$this->assertSame('550 5.7.1 Sending to this recipient is not allowed by the mail policy of the sender',
|
||||
$this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de', 587)->action());
|
||||
$this->assertSame('550 5.7.1 This recipient does not accept mail from this sender',
|
||||
$this->engine->check('x@extern.example', 'azubi@firma.de', '', 25)->action());
|
||||
// quarantine release (590) and BCC/archive copies (591) are not checked
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de', '', 590));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de', '', 591));
|
||||
}
|
||||
|
||||
public function testDovecotSubmissionsCountAsTheMailbox(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(12, ['sendMode' => 'internal'])];
|
||||
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(13, ['receiveMode' => 'internal'])];
|
||||
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
|
||||
// a Sieve redirect of azubi to an external address follows its send rules
|
||||
$this->assertSame([false, 'send'], $this->check('azubi+x@firma.de', 'x@extern.example', '', 588));
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', '', 588));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', '', 588));
|
||||
// other senders are checked like mail from outside
|
||||
$this->assertSame([false, 'receive'], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588));
|
||||
// the quota warning copies of Dovecot carry the address of the mailbox over its quota
|
||||
$this->assertSame([true, ''], $this->check('azubi@firma.de', 'x@extern.example', '', 588, 'quotanotification'));
|
||||
// on other ports the sender address is no login
|
||||
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.de', '', 25));
|
||||
}
|
||||
|
||||
public function testDovecotMailWithoutMailboxSender(): void
|
||||
{
|
||||
// bounces redirected with the null sender, notifications of other senders: local recipients only while
|
||||
// a policy restricts sending, as the sending mailbox is unknown
|
||||
$this->assertSame([true, ''], $this->check('', 'x@extern.example', '', 588));
|
||||
$this->lookup->sendRules = true;
|
||||
$this->assertSame([false, 'send'], $this->check('', 'x@extern.example', '', 588));
|
||||
$this->assertSame([false, 'send'], $this->check('irgendwer@nirgends.example', 'x@extern.example', '', 588));
|
||||
$this->assertSame([true, ''], $this->check('', 'kollege@firma.com', '', 588));
|
||||
// local recipients: receive rules as for mail from outside
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(14, ['sendMode' => 'internal', 'receiveMode' => 'internal'])];
|
||||
$this->assertSame([false, 'receive'], $this->check('', 'azubi@firma.de', '', 588));
|
||||
// SOGo reminders log in with the Dovecot master user
|
||||
$this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'x@extern.example', 'abc@mailcow.local', 588));
|
||||
$this->assertSame([false, 'send'], $this->check('', 'x@extern.example', 'abc@mailcow.local', 588));
|
||||
}
|
||||
|
||||
public function testRecipientsAreComparedAsPostfixDeliversThem(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(15, ['receiveMode' => 'internal'])];
|
||||
|
||||
// trailing dot and quotes are dropped by Postfix
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'Azubi@Firma.de.', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', '"azubi+a b"@firma.com', '', 25));
|
||||
// routes through another address are refused, Postfix would deliver them to the routed address
|
||||
foreach (['azubi@firma.de@localhost', 'azubi%firma.de@localhost', 'firma.de!azubi@localhost.localdomain', 'x@extern.example@localhost',
|
||||
'azubi%firma.de@[192.0.2.1]', 'abuse'] as $recipient) {
|
||||
$this->assertSame([false, 'address'], $this->check('x@extern.example', $recipient, '', 25), $recipient);
|
||||
}
|
||||
// in other domains %, ! and @ in the local part are ordinary characters
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'a!b@extern.example', 'kollege@firma.de'));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'sales%x@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'postmaster', '', 25));
|
||||
$this->assertSame('550 5.7.1 This form of the recipient address is not allowed by the mail policy',
|
||||
$this->engine->check('azubi@firma.de', 'x%extern.example@localhost', 'azubi@firma.de', 587)->action());
|
||||
}
|
||||
|
||||
public function testSendersAreComparedAsPostfixResolvesThem(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(16, ['sendBlock' => ['konkurrenz.example']])];
|
||||
$this->lookup->mailboxPolicies['info@firma.de'] = [self::policy(17, ['sendMode' => 'internal'])];
|
||||
|
||||
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@konkurrenz.example.', 'kollege@firma.de'));
|
||||
// sending as info@ with a trailing dot keeps the policy of info@
|
||||
$this->assertSame([false, 'send'], $this->check('Info@firma.de.', 'x@extern.example', 'chef@firma.de'));
|
||||
}
|
||||
|
||||
public function testTemporaryAliasesFollowTheirOwner(): void
|
||||
{
|
||||
$this->lookup->temporaryAliases['einmal.xyz@firma.de'] = 'azubi@firma.de';
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(18, ['receiveMode' => 'internal'])];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'einmal.xyz@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'einmal.xyz@firma.de', 'kollege@firma.de'));
|
||||
}
|
||||
|
||||
public function testInternalFlagFollowsThePostfixLookup(): void
|
||||
{
|
||||
// an alias of its own in an alias domain; senders of the target domain are internal
|
||||
$this->lookup->aliases['team2@firma.com'] = ['domain' => 'firma.com', 'internal' => true];
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team2@firma.com', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('kollege@firma.de', 'team2@firma.com', 'kollege@firma.de'));
|
||||
// the full address first, then without +tag
|
||||
$this->lookup->aliases['team+ext@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
$this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => false];
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team+ext@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'team+other@firma.de', '', 25));
|
||||
// an internal catch-all alias covers the whole domain, also through its alias domain
|
||||
$this->lookup->internalCatchAlls = ['tochter.de'];
|
||||
$this->lookup->aliasDomains['tochter.com'] = 'tochter.de';
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'andere@tochter.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'unbekannt@tochter.com', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('andere@tochter.de', 'unbekannt@tochter.de', 'andere@tochter.de'));
|
||||
}
|
||||
|
||||
public function testAddressesAreComparedInTheirStoredSpelling(): void
|
||||
{
|
||||
// MySQL ignores accents: Postfix delivers chéf@ to chef@ and töchter.de to tochter.de
|
||||
$this->lookup->spellings = ['chéf@firma.de' => 'chef@firma.de', 'töchter.de' => 'tochter.de', 'köllege@firma.de' => 'kollege@firma.de'];
|
||||
$this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(20, ['sendBlock' => ['chef@firma.de', 'tochter.de']])];
|
||||
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(21, ['receiveBlock' => ['kollege@firma.de']])];
|
||||
|
||||
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'chéf@firma.de', 'kollege@firma.de'));
|
||||
$this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@töchter.de', 'kollege@firma.de'));
|
||||
$this->assertSame([false, 'receive'], $this->check('köllege@firma.de', 'chef@firma.de', '', 25));
|
||||
}
|
||||
|
||||
public function testTaggedAliasThroughAnAliasDomain(): void
|
||||
{
|
||||
// Postfix maps chef@firma.com to chef@firma.de and looks up chef+intern@firma.de again
|
||||
$this->lookup->aliases['chef+intern@firma.de'] = ['domain' => 'firma.de', 'internal' => true];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('x@extern.example', 'chef+intern@firma.com', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('x@extern.example', 'chef+other@firma.com', '', 25));
|
||||
}
|
||||
|
||||
public function testBlockEntriesAlsoMatchTheEnvelopeSender(): void
|
||||
{
|
||||
$this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(19, ['receiveBlock' => ['kollege@firma.de', 'group:7', 'tochter.de']])];
|
||||
|
||||
$this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'chef@firma.de', '', 25));
|
||||
$this->assertSame([true, ''], $this->check('info@firma.de', 'chef@firma.de', '', 25));
|
||||
}
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\MailPolicy\Service;
|
||||
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\Context;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use Mailcow\MailPolicy\Repository\PolicyRepository;
|
||||
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||
use Mailcow\MailPolicy\Service\PolicyService;
|
||||
use Mailcow\Tests\Support\FakeContext;
|
||||
use Mailcow\Tests\Support\FakeMailPolicyLookup;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\MockObject\MockObject;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class PolicyServiceTest extends TestCase
|
||||
{
|
||||
private PolicyRepository&MockObject $repo;
|
||||
private FakeMailPolicyLookup $lookup;
|
||||
/** @var Policy[] saved policies */
|
||||
private array $saved = [];
|
||||
private int $groupLookups = 0;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->repo = $this->createMock(PolicyRepository::class);
|
||||
$this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true));
|
||||
$this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string =>
|
||||
['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null);
|
||||
$this->repo->method('groupDomain')->willReturnCallback(function (int $id): ?string {
|
||||
$this->groupLookups++;
|
||||
return [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null;
|
||||
});
|
||||
$this->repo->method('groupsIn')->willReturn([['id' => 7, 'name' => 'Azubis', 'domain' => 'firma.de', 'address' => null]]);
|
||||
$this->repo->method('save')->willReturnCallback(function (Policy $policy): int {
|
||||
$policy->id ??= 1;
|
||||
$this->saved[] = clone $policy;
|
||||
return $policy->id;
|
||||
});
|
||||
$this->lookup = new FakeMailPolicyLookup();
|
||||
$this->lookup->domains = ['firma.de', 'tochter.de'];
|
||||
$this->lookup->mailboxes = ['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de'];
|
||||
}
|
||||
|
||||
private function service(?Context $context = null): PolicyService
|
||||
{
|
||||
return new PolicyService($this->repo, new PolicyEngine($this->lookup), $context ?? FakeContext::admin());
|
||||
}
|
||||
|
||||
private function stored(array $values = []): Policy
|
||||
{
|
||||
$policy = new Policy();
|
||||
$policy->id = 3;
|
||||
$policy->domain = 'firma.de';
|
||||
$policy->name = 'Azubis';
|
||||
$policy->sendAllow = ['partner.de', 'group:7'];
|
||||
foreach ($values as $property => $value) {
|
||||
$policy->$property = $value;
|
||||
}
|
||||
return $policy;
|
||||
}
|
||||
|
||||
private function expectUserError(string|array $msg, callable $call): void
|
||||
{
|
||||
try {
|
||||
$call();
|
||||
$this->fail('UserError ' . json_encode($msg) . ' expected');
|
||||
} catch (UserError $e) {
|
||||
$this->assertSame($msg, $e->msg());
|
||||
}
|
||||
}
|
||||
|
||||
public function testCreateNormalizesTheInput(): void
|
||||
{
|
||||
$policy = $this->service()->create([
|
||||
'domain' => 'Firma.de', 'name' => 'Azubis', 'description' => 'Nur intern', 'active' => ['0', '1'],
|
||||
'send_mode' => 'internal', 'send_allow' => "@Partner.de\nkunde@extern.example, partner.de", 'send_allow_groups' => ['', '7'],
|
||||
'send_block' => '', 'receive_mode' => 'list', 'receive_allow' => ['group:7'], 'reject_text' => 'Nur intern erlaubt',
|
||||
'mailboxes' => ['', 'Azubi@firma.de'], 'groups' => ['7'],
|
||||
]);
|
||||
|
||||
$this->assertSame([
|
||||
'domain' => 'firma.de', 'name' => 'Azubis', 'description' => 'Nur intern', 'send_mode' => 'internal',
|
||||
'send_allow' => ['partner.de', 'kunde@extern.example', 'group:7'], 'send_block' => [], 'receive_mode' => 'list',
|
||||
'receive_allow' => ['group:7'], 'receive_block' => [], 'reject_text' => 'Nur intern erlaubt', 'active' => 1,
|
||||
'mailboxes' => ['azubi@firma.de'], 'groups' => [7],
|
||||
], array_intersect_key($policy->toArray(), array_flip(['domain', 'name', 'description', 'send_mode', 'send_allow', 'send_block',
|
||||
'receive_mode', 'receive_allow', 'receive_block', 'reject_text', 'active', 'mailboxes', 'groups'])));
|
||||
$this->assertCount(1, $this->saved);
|
||||
}
|
||||
|
||||
public function testUpdateKeepsMissingValuesAndReplacesListGroups(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
|
||||
$policy = $this->service()->update(3, ['description' => 'neu']);
|
||||
$this->assertSame(['neu', ['partner.de', 'group:7']], [$policy->description, $policy->sendAllow]);
|
||||
|
||||
// the UI sends the groups apart: they replace the group entries, the other entries stay
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$this->assertSame(['partner.de'], $this->service()->update(3, ['send_allow_groups' => ''])->sendAllow);
|
||||
}
|
||||
|
||||
public static function invalidInput(): array
|
||||
{
|
||||
return [
|
||||
'empty name' => [['name' => ''], 'mail_policy_name_invalid'],
|
||||
'long name' => [['name' => str_repeat('a', 101)], 'mail_policy_name_invalid'],
|
||||
'mode' => [['send_mode' => 'nobody'], 'mail_policy_mode_invalid'],
|
||||
'domain without dot' => [['send_allow' => 'localhost'], ['mail_policy_entry_invalid', 'localhost']],
|
||||
'address' => [['receive_block' => 'a@@b.de'], ['mail_policy_entry_invalid', 'a@@b.de']],
|
||||
'unknown group' => [['receive_block_groups' => ['8']], ['mail_policy_entry_invalid', 'group:8']],
|
||||
'reject text with line break' => [['reject_text' => "a\r\nb"], 'mail_policy_reject_text_invalid'],
|
||||
'reject text not ASCII' => [['reject_text' => 'Nur für Azubis'], 'mail_policy_reject_text_invalid'],
|
||||
'mailbox of another domain' => [['mailboxes' => 'andere@tochter.de'], ['mail_policy_member_invalid', 'andere@tochter.de']],
|
||||
'group of another domain' => [['groups' => ['9']], ['mail_policy_member_invalid', '9']],
|
||||
'unknown domain' => [['domain' => 'fremd.de'], ['domain_not_found', 'fremd.de']],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('invalidInput')]
|
||||
public function testInvalidInput(array $input, string|array $error): void
|
||||
{
|
||||
$this->expectUserError($error, fn() => $this->service()->create($input + ['domain' => 'firma.de', 'name' => 'Test']));
|
||||
$this->assertSame([], $this->saved);
|
||||
}
|
||||
|
||||
public function testNamesAreUniquePerDomain(): void
|
||||
{
|
||||
$this->repo->method('nameExists')->willReturn(true);
|
||||
$this->expectUserError(['object_exists', 'Azubis'], fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Azubis']));
|
||||
}
|
||||
|
||||
public function testPermissions(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored());
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']);
|
||||
|
||||
$this->assertSame('Test', $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test'])->name);
|
||||
$this->assertNotNull($this->service($da)->get(3));
|
||||
// other domains, no ACL, users and read-only keys
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->create(['domain' => 'tochter.de', 'name' => 'Test']));
|
||||
$this->expectUserError(['mail_policy_entry_invalid', 'group:9'],
|
||||
fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block_groups' => ['9']]));
|
||||
$this->assertNull($this->service(FakeContext::domainAdmin([], ['tochter.de'], ['mail_policies' => '1']))->get(3));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin([], ['firma.de'], []))->create(['domain' => 'firma.de', 'name' => 'Test']));
|
||||
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('azubi@firma.de'))->update(3, []));
|
||||
$readOnly = new Context('admin', 'API', [], false, 'ro');
|
||||
$this->assertNotNull($this->service($readOnly)->get(3));
|
||||
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3));
|
||||
}
|
||||
|
||||
public function testGroupsNeedTheGroupAcl(): void
|
||||
{
|
||||
$this->repo->method('find')->willReturn($this->stored(['groups' => [7]]));
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']);
|
||||
|
||||
$this->assertSame([], $this->service($da)->groupChoices(['firma.de']));
|
||||
// the form sends empty group fields: the group entries and assignments stay
|
||||
$policy = $this->service($da)->update(3, ['send_allow' => 'kunde.example', 'send_allow_groups' => '', 'groups' => '']);
|
||||
$this->assertSame([['kunde.example', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]);
|
||||
// the stored values sent back unchanged, as returned by the API
|
||||
$policy = $this->service($da)->update(3, ['send_allow' => ['partner.de', 'group:7'], 'groups' => ['7']]);
|
||||
$this->assertSame([['partner.de', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]);
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->update(3, ['groups' => ['7', '8']]));
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'groups' => ['7']]));
|
||||
$this->expectUserError(['mail_policy_entry_invalid', 'group:7'],
|
||||
fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => 'group:7']));
|
||||
$this->assertCount(1, $this->service(FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']))->groupChoices(['firma.de']));
|
||||
}
|
||||
|
||||
public function testGroupEntriesBelongToThePolicyDomain(): void
|
||||
{
|
||||
// also for admins: the policy of firma.de cannot name a group of tochter.de
|
||||
$this->expectUserError(['mail_policy_entry_invalid', 'group:9'],
|
||||
fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'receive_block' => 'group:9']));
|
||||
}
|
||||
|
||||
public function testListsAreLimitedBeforeAnythingIsLookedUp(): void
|
||||
{
|
||||
$groups = array_map(fn(int $i): string => 'group:' . $i, range(1, 501));
|
||||
$this->expectUserError(['mail_policy_entries_exceeded', 500], fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => $groups]));
|
||||
$this->assertSame(0, $this->groupLookups);
|
||||
// repeats count once
|
||||
$this->assertSame(['group:7'], $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => array_fill(0, 600, 'group:7')])->sendBlock);
|
||||
$this->expectUserError(['mail_policy_entries_exceeded', 500],
|
||||
fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'mailboxes' => array_map(fn(int $i): string => "m$i@firma.de", range(1, 501))]));
|
||||
}
|
||||
|
||||
public function testRuleTestShowsNothingOfOtherDomains(): void
|
||||
{
|
||||
$this->lookup->mailboxes['andere@tochter.de'] = 'tochter.de';
|
||||
$this->lookup->mailboxPolicies['andere@tochter.de'] = [$this->stored(['id' => 5, 'domain' => 'tochter.de', 'name' => 'Geheim',
|
||||
'sendAllow' => [], 'receiveMode' => 'internal', 'rejectText' => 'Interner Text'])];
|
||||
$this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => $this->lookup->mailboxes[$username] ?? null);
|
||||
$da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']);
|
||||
|
||||
// a policy of another domain: neither name nor text
|
||||
$this->assertSame(['allowed' => 0, 'side' => 'receive', 'policy' => null, 'internal_alias' => 0, 'logged_in' => 1,
|
||||
'text' => 'This recipient does not accept mail from this sender'], $this->service($da)->test('azubi@firma.de', 'andere@tochter.de', true));
|
||||
// a sender of another domain is not tested as logged in, so its mailbox and its rules stay hidden
|
||||
$this->assertSame(0, $this->service($da)->test('andere@tochter.de', 'chef@firma.de', true)['logged_in']);
|
||||
}
|
||||
|
||||
public function testRuleTest(): void
|
||||
{
|
||||
$policy = $this->stored(['sendMode' => 'internal', 'sendAllow' => []]);
|
||||
$this->lookup->mailboxPolicies['azubi@firma.de'] = [$policy];
|
||||
|
||||
$this->assertSame(['allowed' => 0, 'side' => 'send', 'policy' => 'Azubis', 'internal_alias' => 0, 'logged_in' => 1,
|
||||
'text' => 'Sending to this recipient is not allowed by the mail policy of the sender'],
|
||||
$this->service()->test('azubi@firma.de', 'x@extern.example', true));
|
||||
// not logged in: the send rules of the mailbox do not apply
|
||||
$this->assertSame(1, $this->service()->test('azubi@firma.de', 'x@extern.example', false)['allowed']);
|
||||
// an external sender is never logged in
|
||||
$this->assertSame(0, $this->service()->test('x@extern.example', 'chef@firma.de', true)['logged_in']);
|
||||
$this->expectUserError('mail_policy_test_invalid', fn() => $this->service()->test('azubi@firma.de', 'kein-empfaenger', true));
|
||||
// domain admins test mail of their domains
|
||||
$da = FakeContext::domainAdmin([], ['tochter.de'], ['mail_policies' => '1']);
|
||||
$this->expectUserError('access_denied', fn() => $this->service($da)->test('azubi@firma.de', 'x@extern.example', true));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Support;
|
||||
|
||||
use Mailcow\Archive\Model\Mailbox;
|
||||
use Mailcow\Archive\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Address tables in memory, with the conditions of the SQL already applied: only active rows are listed,
|
||||
* $mailboxes holds the mailboxes that receive mail as username => [archive setting, domain archive].
|
||||
*/
|
||||
class FakeArchiveLookup extends LookupRepository
|
||||
{
|
||||
public array $recipientMaps = [];
|
||||
public array $aliases = [];
|
||||
public array $resources = [];
|
||||
public array $spamAliases = [];
|
||||
/** alias domain => target domain */
|
||||
public array $aliasDomains = [];
|
||||
public array $mailboxes = [];
|
||||
/** ['rcpt' => [key => dest], 'sender' => [key => dest]] */
|
||||
public array $bcc = ['rcpt' => [], 'sender' => []];
|
||||
/** @var string[] domains and alias domains of the mailcow */
|
||||
public array $domains = [];
|
||||
/** @var string[] every lookup as "<method>:<key>" */
|
||||
public array $queries = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function recipientMap(string $key): ?string
|
||||
{
|
||||
$this->queries[] = 'recipientMap:' . $key;
|
||||
return $this->recipientMaps[$key] ?? null;
|
||||
}
|
||||
|
||||
public function aliasGoto(string $key): ?string
|
||||
{
|
||||
$this->queries[] = 'aliasGoto:' . $key;
|
||||
return $this->aliases[$key] ?? null;
|
||||
}
|
||||
|
||||
public function isResource(string $username): bool
|
||||
{
|
||||
return in_array($username, $this->resources, true);
|
||||
}
|
||||
|
||||
public function spamAliasGoto(string $key): ?string
|
||||
{
|
||||
return $this->spamAliases[$key] ?? null;
|
||||
}
|
||||
|
||||
public function aliasDomainMailbox(string $local, string $domain): ?string
|
||||
{
|
||||
$target = $this->aliasDomains[$domain] ?? null;
|
||||
return ($target !== null && isset($this->mailboxes[$local . '@' . $target])) ? $local . '@' . $target : null;
|
||||
}
|
||||
|
||||
public function bccDestination(string $key, string $type): ?string
|
||||
{
|
||||
return $this->bcc[$type][$key] ?? null;
|
||||
}
|
||||
|
||||
public function isMailcowDomain(string $domain): bool
|
||||
{
|
||||
return in_array($domain, $this->domains, true);
|
||||
}
|
||||
|
||||
public function mailbox(string $username): ?Mailbox
|
||||
{
|
||||
if (!isset($this->mailboxes[$username])) {
|
||||
return null;
|
||||
}
|
||||
[$archive, $domainArchive] = $this->mailboxes[$username];
|
||||
return Mailbox::fromRow(['username' => $username, 'domain' => substr($username, strrpos($username, '@') + 1),
|
||||
'archive' => $archive, 'domain_archive' => $domainArchive, 'name' => '', 'active' => 1]);
|
||||
}
|
||||
|
||||
public function archivedMailboxes(): array
|
||||
{
|
||||
return array_values(array_filter(array_map(fn(string $username): Mailbox => $this->mailbox($username), array_keys($this->mailboxes)),
|
||||
fn(Mailbox $mailbox): bool => $mailbox->archived()));
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Support;
|
||||
|
||||
use Mailcow\MailPolicy\Model\Policy;
|
||||
use Mailcow\MailPolicy\Repository\LookupRepository;
|
||||
|
||||
/**
|
||||
* Address tables and policy assignments in memory, with the conditions of the SQL already applied
|
||||
* (only active rows are listed).
|
||||
*/
|
||||
class FakeMailPolicyLookup extends LookupRepository
|
||||
{
|
||||
/** username => domain */
|
||||
public array $mailboxes = [];
|
||||
/** alias domain => target domain */
|
||||
public array $aliasDomains = [];
|
||||
/** @var string[] domains of the mailcow (without alias domains) */
|
||||
public array $domains = [];
|
||||
/** id => ['address' => ?string, 'members' => string[]] */
|
||||
public array $groups = [];
|
||||
/** alias address => ['domain' => string, 'internal' => bool] (active aliases, also own aliases of mailboxes) */
|
||||
public array $aliases = [];
|
||||
/** temporary alias => owner mailbox */
|
||||
public array $temporaryAliases = [];
|
||||
/** @var string[] domains with an internal catch-all alias */
|
||||
public array $internalCatchAlls = [];
|
||||
/** an active policy restricts sending */
|
||||
public bool $sendRules = false;
|
||||
/** other spelling (MySQL ignores accents) => stored address or domain */
|
||||
public array $spellings = [];
|
||||
/** username => Policy[] assigned directly */
|
||||
public array $mailboxPolicies = [];
|
||||
/** group id => Policy[] */
|
||||
public array $groupPolicies = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function mailbox(string $username): ?array
|
||||
{
|
||||
return isset($this->mailboxes[$username]) ? ['username' => $username, 'domain' => $this->mailboxes[$username]] : null;
|
||||
}
|
||||
|
||||
public function aliasDomainTarget(string $domain): ?string
|
||||
{
|
||||
return $this->aliasDomains[$domain] ?? null;
|
||||
}
|
||||
|
||||
public function isLocalDomain(string $domain): bool
|
||||
{
|
||||
return in_array($domain, $this->domains, true) || isset($this->aliasDomains[$domain]);
|
||||
}
|
||||
|
||||
public function groupByAddress(string $address): ?int
|
||||
{
|
||||
foreach ($this->groups as $id => $group) {
|
||||
if (($group['address'] ?? null) === $address) {
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function groupAddress(int $id): ?string
|
||||
{
|
||||
return $this->groups[$id]['address'] ?? null;
|
||||
}
|
||||
|
||||
public function isGroupMember(int $groupId, string $username): bool
|
||||
{
|
||||
return in_array($username, $this->groups[$groupId]['members'] ?? [], true);
|
||||
}
|
||||
|
||||
public function aliasTarget(string $address): ?array
|
||||
{
|
||||
if (isset($this->aliases[$address])) {
|
||||
return ['kind' => 'alias', 'domain' => $this->aliases[$address]['domain'], 'internal' => $this->aliases[$address]['internal']];
|
||||
}
|
||||
return isset($this->temporaryAliases[$address]) ? ['kind' => 'temporary', 'owner' => $this->temporaryAliases[$address]] : null;
|
||||
}
|
||||
|
||||
public function internalCatchAll(string $domain): bool
|
||||
{
|
||||
return in_array($domain, $this->internalCatchAlls, true);
|
||||
}
|
||||
|
||||
public function hasSendRules(): bool
|
||||
{
|
||||
return $this->sendRules;
|
||||
}
|
||||
|
||||
public function storedAddress(string $address): ?string
|
||||
{
|
||||
$address = $this->spellings[$address] ?? $address;
|
||||
return (isset($this->mailboxes[$address]) || isset($this->aliases[$address]) || isset($this->temporaryAliases[$address])) ? $address : null;
|
||||
}
|
||||
|
||||
public function storedDomain(string $domain): ?string
|
||||
{
|
||||
$domain = $this->spellings[$domain] ?? $domain;
|
||||
return (in_array($domain, $this->domains, true) || isset($this->aliasDomains[$domain])) ? $domain : null;
|
||||
}
|
||||
|
||||
public function policiesForMailbox(string $username): array
|
||||
{
|
||||
$policies = $this->mailboxPolicies[$username] ?? [];
|
||||
foreach ($this->groups as $id => $group) {
|
||||
if (in_array($username, $group['members'] ?? [], true)) {
|
||||
$policies = array_merge($policies, $this->groupPolicies[$id] ?? []);
|
||||
}
|
||||
}
|
||||
return $policies;
|
||||
}
|
||||
|
||||
public function policiesForGroup(int $groupId): array
|
||||
{
|
||||
return $this->groupPolicies[$groupId] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Support;
|
||||
|
||||
/**
|
||||
* The few Redis commands of the archive state, in memory (the test image has no redis extension).
|
||||
*/
|
||||
class FakeRedis
|
||||
{
|
||||
public array $lists = [];
|
||||
public array $strings = [];
|
||||
|
||||
public function set(string $key, string $value): bool
|
||||
{
|
||||
$this->strings[$key] = $value;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function get(string $key): string|false
|
||||
{
|
||||
return $this->strings[$key] ?? false;
|
||||
}
|
||||
|
||||
public function incr(string $key): int
|
||||
{
|
||||
$this->strings[$key] = (string)((int)($this->strings[$key] ?? 0) + 1);
|
||||
return (int)$this->strings[$key];
|
||||
}
|
||||
|
||||
public function lPush(string $key, string $value): int
|
||||
{
|
||||
$this->lists[$key] ??= [];
|
||||
array_unshift($this->lists[$key], $value);
|
||||
return count($this->lists[$key]);
|
||||
}
|
||||
|
||||
public function lTrim(string $key, int $start, int $stop): bool
|
||||
{
|
||||
$this->lists[$key] = array_slice($this->lists[$key] ?? [], $start, $stop - $start + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Like Redis, a negative stop counts from the end (-1 is the last element) */
|
||||
public function lRange(string $key, int $start, int $stop): array
|
||||
{
|
||||
$list = $this->lists[$key] ?? [];
|
||||
if ($stop < 0) {
|
||||
$stop += count($list);
|
||||
}
|
||||
return array_slice($list, $start, max(0, $stop - $start + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Mailcow\Tests\Support;
|
||||
|
||||
use Mailcow\Archive\Smtp\SmtpClient;
|
||||
use Mailcow\Archive\Smtp\SmtpException;
|
||||
|
||||
/**
|
||||
* SmtpClient without network: records submit() and probe() calls, answers with the configured results.
|
||||
*/
|
||||
class FakeSmtpClient extends SmtpClient
|
||||
{
|
||||
/** @var array[] submit() calls: host, port, from, rcpt, data */
|
||||
public array $submitted = [];
|
||||
/** @var array[] probe() calls */
|
||||
public array $probed = [];
|
||||
public ?SmtpException $error = null;
|
||||
/** @var string[] extensions probe() reports */
|
||||
public array $extensions = ['PIPELINING', 'SIZE', 'STARTTLS', '8BITMIME', 'SMTPUTF8'];
|
||||
public array $mx = [];
|
||||
|
||||
public function submit(string $host, int $port, string $from, string $rcpt, string $data): string
|
||||
{
|
||||
if ($this->error !== null) {
|
||||
throw $this->error;
|
||||
}
|
||||
$this->submitted[] = compact('host', 'port', 'from', 'rcpt', 'data');
|
||||
return 'Q' . count($this->submitted);
|
||||
}
|
||||
|
||||
public function probe(string $host, int $port, string $tls, string $username, string $password, string $from, string $rcpt): array
|
||||
{
|
||||
$this->probed[] = compact('host', 'port', 'tls', 'username', 'password', 'from', 'rcpt');
|
||||
if ($this->error !== null) {
|
||||
throw $this->error;
|
||||
}
|
||||
return $this->extensions;
|
||||
}
|
||||
|
||||
public function mailHost(string $domain): string
|
||||
{
|
||||
return $this->mx[$domain] ?? $domain;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user