mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-07 09:19:21 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0175fce9b5 | ||
|
|
1c24de19c3 | ||
|
|
fb85a9eb5c | ||
|
|
fc56d53029 | ||
|
|
2ac2edf0ad |
No files matched your search
@@ -15,6 +15,21 @@ until dig +short mailcow.email > /dev/null; do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Wait for updated schema (DB_VERSION from docker-compose.yml), the maps read tables the init_db of php-fpm creates on updates
|
||||||
|
DBV_NOW=$(mariadb --ssl=false --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null)
|
||||||
|
DBV_WAIT=0
|
||||||
|
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
|
||||||
|
echo "Waiting for schema update..."
|
||||||
|
sleep 5
|
||||||
|
DBV_WAIT=$((DBV_WAIT + 1))
|
||||||
|
DBV_NOW=$(mariadb --ssl=false --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null)
|
||||||
|
done
|
||||||
|
if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
|
||||||
|
echo -e "\e[31mDB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting anyway, check the php-fpm log (init_db)\e[0m"
|
||||||
|
else
|
||||||
|
echo "DB schema is ${DBV_NOW}"
|
||||||
|
fi
|
||||||
|
|
||||||
cat <<EOF > /etc/aliases
|
cat <<EOF > /etc/aliases
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
null: /dev/null
|
null: /dev/null
|
||||||
@@ -163,6 +178,45 @@ query = SELECT CONCAT('smtp_via_transport_maps:', nexthop) AS transport FROM tra
|
|||||||
AND destination = '%s';
|
AND destination = '%s';
|
||||||
EOF
|
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
|
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_resource_maps.cf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
user = ${DBUSER}
|
user = ${DBUSER}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ $_SESSION['acl']['sogo_access'] = "1";
|
|||||||
$_SESSION['acl']['protocol_access'] = "1";
|
$_SESSION['acl']['protocol_access'] = "1";
|
||||||
$_SESSION['acl']['mailbox_relayhost'] = "1";
|
$_SESSION['acl']['mailbox_relayhost'] = "1";
|
||||||
$_SESSION['acl']['unlimited_quota'] = "1";
|
$_SESSION['acl']['unlimited_quota'] = "1";
|
||||||
|
$_SESSION['acl']['archive'] = "1";
|
||||||
|
|
||||||
// One run at a time, released when the process ends
|
// One run at a time, released when the process ends
|
||||||
$lock = fopen('/tmp/identity-sync.lock', 'c');
|
$lock = fopen('/tmp/identity-sync.lock', 'c');
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
/watchdog@localhost$/ watchdog_discard:
|
/watchdog@localhost$/ watchdog_discard:
|
||||||
|
/^archive-bounce@localhost$/ archive_discard:
|
||||||
/localhost$/ local:
|
/localhost$/ local:
|
||||||
@@ -50,6 +50,9 @@ postscreen_greet_wait = 3s
|
|||||||
postscreen_non_smtp_command_enable = no
|
postscreen_non_smtp_command_enable = no
|
||||||
postscreen_pipelining_enable = no
|
postscreen_pipelining_enable = no
|
||||||
proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf,
|
proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_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_mbr_access_maps.cf,
|
||||||
proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
||||||
$sender_dependent_default_transport_maps,
|
$sender_dependent_default_transport_maps,
|
||||||
|
|||||||
@@ -61,13 +61,14 @@ submission inet n - n - - smtpd
|
|||||||
-o non_smtpd_milters=
|
-o non_smtpd_milters=
|
||||||
-o syslog_name=postfix/quarantine
|
-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
|
591 inet n - n - - smtpd
|
||||||
-o smtpd_helo_restrictions=
|
-o smtpd_helo_restrictions=
|
||||||
-o smtpd_client_restrictions=permit_mynetworks,reject
|
-o smtpd_client_restrictions=permit_mynetworks,reject
|
||||||
-o smtpd_tls_auth_only=no
|
-o smtpd_tls_auth_only=no
|
||||||
-o smtpd_milters=
|
-o smtpd_milters=
|
||||||
-o non_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
|
-o syslog_name=postfix/bcc
|
||||||
|
|
||||||
# enforced smtp connector
|
# enforced smtp connector
|
||||||
@@ -81,6 +82,27 @@ smtp_enforced_tls unix - - n - - smtp
|
|||||||
smtp_via_transport_maps 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
|
-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
|
tlsproxy unix - - n - 0 tlsproxy
|
||||||
dnsblog unix - - n - 0 dnsblog
|
dnsblog unix - - n - 0 dnsblog
|
||||||
pickup fifo n - n 60 1 pickup
|
pickup fifo n - n 60 1 pickup
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -956,3 +956,138 @@ rspamd_config:register_symbol({
|
|||||||
end,
|
end,
|
||||||
priority = 1
|
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
|
||||||
|
})
|
||||||
@@ -46,6 +46,7 @@ $template_data = [
|
|||||||
'lang_edit' => json_encode($lang['edit']),
|
'lang_edit' => json_encode($lang['edit']),
|
||||||
'lang_datatables' => json_encode($lang['datatables']),
|
'lang_datatables' => json_encode($lang['datatables']),
|
||||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
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/presets/rspamd.js');
|
||||||
$js_minifier->add('/web/js/site/pwgen.js');
|
$js_minifier->add('/web/js/site/pwgen.js');
|
||||||
$js_minifier->add('/web/js/site/identity.js');
|
$js_minifier->add('/web/js/site/identity.js');
|
||||||
|
$js_minifier->add('/web/js/site/archive.js');
|
||||||
|
|
||||||
// all domains
|
// all domains
|
||||||
$domains = mailbox('get', 'domains');
|
$domains = mailbox('get', 'domains');
|
||||||
@@ -116,6 +117,7 @@ $template_data = [
|
|||||||
'ui_theme' => customize('get', 'ui_theme'),
|
'ui_theme' => customize('get', 'ui_theme'),
|
||||||
'password_complexity' => password_complexity('get'),
|
'password_complexity' => password_complexity('get'),
|
||||||
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
|
'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'],
|
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
|
||||||
'cors_settings' => $cors_settings,
|
'cors_settings' => $cors_settings,
|
||||||
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
||||||
|
|||||||
@@ -685,6 +685,11 @@ paths:
|
|||||||
is domain global address list active or not, it enables
|
is domain global address list active or not, it enables
|
||||||
shared contacts accross domain in SOGo webmail
|
shared contacts accross domain in SOGo webmail
|
||||||
type: boolean
|
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:
|
mailboxes:
|
||||||
description: limit count of mailboxes associated with this domain
|
description: limit count of mailboxes associated with this domain
|
||||||
type: number
|
type: number
|
||||||
@@ -1254,6 +1259,12 @@ paths:
|
|||||||
force_tfa:
|
force_tfa:
|
||||||
description: force 2FA enrollment at login
|
description: force 2FA enrollment at login
|
||||||
type: boolean
|
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:
|
tls_enforce_in:
|
||||||
description: force inbound email tls encryption
|
description: force inbound email tls encryption
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -3918,6 +3929,11 @@ paths:
|
|||||||
is domain global address list active or not, it enables
|
is domain global address list active or not, it enables
|
||||||
shared contacts accross domain in SOGo webmail
|
shared contacts accross domain in SOGo webmail
|
||||||
type: boolean
|
type: boolean
|
||||||
|
archive:
|
||||||
|
description: >-
|
||||||
|
archive mails of the mailboxes of this domain by default (admins, domain admins with the
|
||||||
|
archive permission)
|
||||||
|
type: boolean
|
||||||
mailboxes:
|
mailboxes:
|
||||||
description: limit count of mailboxes associated with this domain
|
description: limit count of mailboxes associated with this domain
|
||||||
type: number
|
type: number
|
||||||
@@ -4222,6 +4238,12 @@ paths:
|
|||||||
force_tfa:
|
force_tfa:
|
||||||
description: force 2FA enrollment at login
|
description: force 2FA enrollment at login
|
||||||
type: boolean
|
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:
|
name:
|
||||||
description: Full name of the mailbox user
|
description: Full name of the mailbox user
|
||||||
type: string
|
type: string
|
||||||
@@ -4546,6 +4568,104 @@ paths:
|
|||||||
default: run
|
default: run
|
||||||
type: object
|
type: object
|
||||||
summary: Request identity sync run
|
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:
|
/api/v1/edit/mailq:
|
||||||
post:
|
post:
|
||||||
responses:
|
responses:
|
||||||
@@ -5758,6 +5878,7 @@ paths:
|
|||||||
description: Some description
|
description: Some description
|
||||||
domain_name: domain.tld
|
domain_name: domain.tld
|
||||||
gal: "0"
|
gal: "0"
|
||||||
|
archive: 1
|
||||||
max_new_mailbox_quota: 10737418240
|
max_new_mailbox_quota: 10737418240
|
||||||
max_num_aliases_for_domain: 400
|
max_num_aliases_for_domain: 400
|
||||||
max_num_mboxes_for_domain: 10
|
max_num_mboxes_for_domain: 10
|
||||||
@@ -6312,7 +6433,9 @@ paths:
|
|||||||
response:
|
response:
|
||||||
value:
|
value:
|
||||||
- active: "1"
|
- active: "1"
|
||||||
|
archive: 1
|
||||||
attributes:
|
attributes:
|
||||||
|
archive: inherit
|
||||||
force_pw_update: "0"
|
force_pw_update: "0"
|
||||||
force_tfa: "0"
|
force_tfa: "0"
|
||||||
mailbox_format: "maildir:"
|
mailbox_format: "maildir:"
|
||||||
@@ -6507,6 +6630,154 @@ paths:
|
|||||||
nothing was changed).
|
nothing was changed).
|
||||||
operationId: Get identity sync status
|
operationId: Get identity sync status
|
||||||
summary: 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}":
|
"/api/v1/get/identity-link/{username}":
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -7597,7 +7868,9 @@ paths:
|
|||||||
response:
|
response:
|
||||||
value:
|
value:
|
||||||
- active: "1"
|
- active: "1"
|
||||||
|
archive: 1
|
||||||
attributes:
|
attributes:
|
||||||
|
archive: inherit
|
||||||
force_pw_update: "0"
|
force_pw_update: "0"
|
||||||
force_tfa: "0"
|
force_tfa: "0"
|
||||||
mailbox_format: "maildir:"
|
mailbox_format: "maildir:"
|
||||||
@@ -8025,3 +8298,7 @@ tags:
|
|||||||
description: Manage Cross-Origin Resource Sharing (CORS) settings
|
description: Manage Cross-Origin Resource Sharing (CORS) settings
|
||||||
- name: Identity Provider
|
- name: Identity Provider
|
||||||
description: Manage external Identity Provider settings
|
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.
|
||||||
@@ -45,6 +45,7 @@ $template_data = [
|
|||||||
'lang_edit' => json_encode($lang['edit']),
|
'lang_edit' => json_encode($lang['edit']),
|
||||||
'lang_datatables' => json_encode($lang['datatables']),
|
'lang_datatables' => json_encode($lang['datatables']),
|
||||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||||
+5
-1
@@ -65,7 +65,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
'mta_sts' => $mta_sts,
|
'mta_sts' => $mta_sts,
|
||||||
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
|
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
|
||||||
'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'),
|
'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_data = [
|
||||||
'template' => $domain_template,
|
'template' => $domain_template,
|
||||||
'rl' => ['frame' => $domain_template['attributes']['rl_frame']],
|
'rl' => ['frame' => $domain_template['attributes']['rl_frame']],
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
$template = 'edit/domain-templates.twig';
|
$template = 'edit/domain-templates.twig';
|
||||||
$result = true;
|
$result = true;
|
||||||
@@ -85,6 +87,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
$template_data = [
|
$template_data = [
|
||||||
'template' => $mailbox_template,
|
'template' => $mailbox_template,
|
||||||
'rl' => ['frame' => $mailbox_template['attributes']['rl_frame']],
|
'rl' => ['frame' => $mailbox_template['attributes']['rl_frame']],
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
$template = 'edit/mailbox-templates.twig';
|
$template = 'edit/mailbox-templates.twig';
|
||||||
$result = true;
|
$result = true;
|
||||||
@@ -162,6 +165,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
'user_acls' => acl('get', 'user', $mailbox),
|
'user_acls' => acl('get', 'user', $mailbox),
|
||||||
'mailbox_details' => $result,
|
'mailbox_details' => $result,
|
||||||
'iam_settings' => $iam_settings,
|
'iam_settings' => $iam_settings,
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -539,6 +539,7 @@ function sys_mail($_data) {
|
|||||||
$mail->Body = $mass_text;
|
$mail->Body = $mass_text;
|
||||||
}
|
}
|
||||||
$mail->XMailer = 'MooMassMail';
|
$mail->XMailer = 'MooMassMail';
|
||||||
|
$archive = \Mailcow\Archive\ArchiveModule::fromGlobals();
|
||||||
foreach ($rcpts as $rcpt) {
|
foreach ($rcpts as $rcpt) {
|
||||||
$mail->AddAddress($rcpt);
|
$mail->AddAddress($rcpt);
|
||||||
if (!$mail->send()) {
|
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)
|
'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();
|
$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'];
|
$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'];
|
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx'];
|
||||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal'];
|
$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) {
|
if ($relay_all_recipients == 1) {
|
||||||
$backupmx = '1';
|
$backupmx = '1';
|
||||||
}
|
}
|
||||||
@@ -421,8 +422,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':domain' => '%@' . $domain
|
':domain' => '%@' . $domain
|
||||||
));
|
));
|
||||||
// save 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`)
|
$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)");
|
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients, :archive)");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':domain' => $domain,
|
':domain' => $domain,
|
||||||
':description' => $description,
|
':description' => $description,
|
||||||
@@ -435,7 +436,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':gal' => $gal,
|
':gal' => $gal,
|
||||||
':active' => $active,
|
':active' => $active,
|
||||||
':relay_unknown_only' => $relay_unknown_only,
|
':relay_unknown_only' => $relay_unknown_only,
|
||||||
':relay_all_recipients' => $relay_all_recipients
|
':relay_all_recipients' => $relay_all_recipients,
|
||||||
|
':archive' => $archive
|
||||||
));
|
));
|
||||||
// save tags
|
// save tags
|
||||||
foreach($tags as $index => $tag){
|
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;
|
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0;
|
||||||
// Shared mailbox: no login of its own, members open it through group shares
|
// Shared mailbox: no login of its own, members open it through group shares
|
||||||
$shared = (isset($_data['shared'])) ? intval($_data['shared']) : 0;
|
$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_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']);
|
$quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
|
||||||
// Validate quarantine_category
|
// Validate quarantine_category
|
||||||
@@ -974,6 +978,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
'dav_access' => strval($dav_access),
|
'dav_access' => strval($dav_access),
|
||||||
'relayhost' => strval($relayhost),
|
'relayhost' => strval($relayhost),
|
||||||
'shared' => strval($shared),
|
'shared' => strval($shared),
|
||||||
|
'archive' => $archive,
|
||||||
'passwd_update' => time(),
|
'passwd_update' => time(),
|
||||||
'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']),
|
'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']),
|
||||||
'quarantine_notification' => strval($quarantine_notification),
|
'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['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_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['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['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
||||||
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
$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["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_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["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["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["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']);
|
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
|
||||||
@@ -2488,6 +2495,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
$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'];
|
$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']);
|
(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());
|
$tags = (is_array($_data['tags']) ? $_data['tags'] : array());
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -2501,11 +2509,13 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
|
|
||||||
$stmt = $pdo->prepare("UPDATE `domain` SET
|
$stmt = $pdo->prepare("UPDATE `domain` SET
|
||||||
`description` = :description,
|
`description` = :description,
|
||||||
`gal` = :gal
|
`gal` = :gal,
|
||||||
|
`archive` = :archive
|
||||||
WHERE `domain` = :domain");
|
WHERE `domain` = :domain");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':description' => $description,
|
':description' => $description,
|
||||||
':gal' => $gal,
|
':gal' => $gal,
|
||||||
|
':archive' => $archive,
|
||||||
':domain' => $domain
|
':domain' => $domain
|
||||||
));
|
));
|
||||||
// save tags
|
// save tags
|
||||||
@@ -2538,6 +2548,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
||||||
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx'];
|
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx'];
|
||||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
$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_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'];
|
$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'];
|
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : $is_now['relayhost'];
|
||||||
@@ -2652,6 +2663,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
`relay_unknown_only` = :relay_unknown_only,
|
`relay_unknown_only` = :relay_unknown_only,
|
||||||
`backupmx` = :backupmx,
|
`backupmx` = :backupmx,
|
||||||
`gal` = :gal,
|
`gal` = :gal,
|
||||||
|
`archive` = :archive,
|
||||||
`active` = :active,
|
`active` = :active,
|
||||||
`quota` = :quota,
|
`quota` = :quota,
|
||||||
`defquota` = :defquota,
|
`defquota` = :defquota,
|
||||||
@@ -2666,6 +2678,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':relay_unknown_only' => $relay_unknown_only,
|
':relay_unknown_only' => $relay_unknown_only,
|
||||||
':backupmx' => $backupmx,
|
':backupmx' => $backupmx,
|
||||||
':gal' => $gal,
|
':gal' => $gal,
|
||||||
|
':archive' => $archive,
|
||||||
':active' => $active,
|
':active' => $active,
|
||||||
':quota' => $quota,
|
':quota' => $quota,
|
||||||
':defquota' => $defquota,
|
':defquota' => $defquota,
|
||||||
@@ -2753,6 +2766,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
|
$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_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['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['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
||||||
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
||||||
|
|
||||||
@@ -2814,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)$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)$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']);
|
(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)$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);
|
(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'];
|
$name = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name'];
|
||||||
@@ -3114,6 +3129,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
`attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
|
`attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
|
||||||
`attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_access),
|
`attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_access),
|
||||||
`attributes` = JSON_SET(`attributes`, '$.dav_access', :dav_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`, '$.recovery_email', :recovery_email),
|
||||||
`attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
|
`attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
|
||||||
WHERE `username` = :username");
|
WHERE `username` = :username");
|
||||||
@@ -3131,6 +3147,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':smtp_access' => $smtp_access,
|
':smtp_access' => $smtp_access,
|
||||||
':eas_access' => $eas_access,
|
':eas_access' => $eas_access,
|
||||||
':dav_access' => $dav_access,
|
':dav_access' => $dav_access,
|
||||||
|
':archive' => $archive,
|
||||||
':recovery_email' => $pw_recovery_email,
|
':recovery_email' => $pw_recovery_email,
|
||||||
':relayhost' => $relayhost,
|
':relayhost' => $relayhost,
|
||||||
':shared' => strval($shared),
|
':shared' => strval($shared),
|
||||||
@@ -3534,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["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_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["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["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["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'];
|
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : $is_now['tls_enforce_in'];
|
||||||
@@ -4682,6 +4700,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
`relay_unknown_only`,
|
`relay_unknown_only`,
|
||||||
`backupmx`,
|
`backupmx`,
|
||||||
`gal`,
|
`gal`,
|
||||||
|
`archive`,
|
||||||
`active`
|
`active`
|
||||||
FROM `domain` WHERE `domain`= :domain");
|
FROM `domain` WHERE `domain`= :domain");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
@@ -4742,6 +4761,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$domaindata['backupmx_int'] = $row['backupmx'];
|
$domaindata['backupmx_int'] = $row['backupmx'];
|
||||||
$domaindata['gal'] = $row['gal'];
|
$domaindata['gal'] = $row['gal'];
|
||||||
$domaindata['gal_int'] = $row['gal'];
|
$domaindata['gal_int'] = $row['gal'];
|
||||||
|
$domaindata['archive'] = $row['archive'];
|
||||||
$domaindata['rl'] = $rl;
|
$domaindata['rl'] = $rl;
|
||||||
$domaindata['active'] = $row['active'];
|
$domaindata['active'] = $row['active'];
|
||||||
$domaindata['active_int'] = $row['active'];
|
$domaindata['active_int'] = $row['active'];
|
||||||
@@ -4829,6 +4849,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
if (preg_match('/y|yes/i', getenv('MASTER'))) {
|
if (preg_match('/y|yes/i', getenv('MASTER'))) {
|
||||||
$stmt = $pdo->prepare("SELECT
|
$stmt = $pdo->prepare("SELECT
|
||||||
`domain`.`backupmx`,
|
`domain`.`backupmx`,
|
||||||
|
`domain`.`active` AS `domain_active`,
|
||||||
|
`domain`.`archive` AS `domain_archive`,
|
||||||
`mailbox`.`username`,
|
`mailbox`.`username`,
|
||||||
`mailbox`.`name`,
|
`mailbox`.`name`,
|
||||||
`mailbox`.`active`,
|
`mailbox`.`active`,
|
||||||
@@ -4851,6 +4873,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
else {
|
else {
|
||||||
$stmt = $pdo->prepare("SELECT
|
$stmt = $pdo->prepare("SELECT
|
||||||
`domain`.`backupmx`,
|
`domain`.`backupmx`,
|
||||||
|
`domain`.`active` AS `domain_active`,
|
||||||
|
`domain`.`archive` AS `domain_archive`,
|
||||||
`mailbox`.`username`,
|
`mailbox`.`username`,
|
||||||
`mailbox`.`name`,
|
`mailbox`.`name`,
|
||||||
`mailbox`.`active`,
|
`mailbox`.`active`,
|
||||||
@@ -4890,6 +4914,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$mailboxdata['created'] = $row['created'];
|
$mailboxdata['created'] = $row['created'];
|
||||||
$mailboxdata['modified'] = $row['modified'];
|
$mailboxdata['modified'] = $row['modified'];
|
||||||
$mailboxdata['authsource'] = ($row['authsource']) ? $row['authsource'] : 'mailcow';
|
$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'] === '- ') {
|
if ($mailboxdata['percent_in_use'] === '- ') {
|
||||||
$mailboxdata['percent_class'] = "info";
|
$mailboxdata['percent_class'] = "info";
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
$mail->addAttachment($msg_tmpf, $detail_row['qid'] . '.eml');
|
$mail->addAttachment($msg_tmpf, $detail_row['qid'] . '.eml');
|
||||||
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
||||||
$mail->send();
|
$mail->send();
|
||||||
|
$released_msg = $mail->getSentMIMEMessage();
|
||||||
unlink($msg_tmpf);
|
unlink($msg_tmpf);
|
||||||
}
|
}
|
||||||
catch (phpmailerException $e) {
|
catch (phpmailerException $e) {
|
||||||
@@ -170,6 +171,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
elseif ($release_format == 'raw') {
|
elseif ($release_format == 'raw') {
|
||||||
$detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
|
$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
|
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||||
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
|
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
|
||||||
$postfix_talk = array(
|
$postfix_talk = array(
|
||||||
@@ -213,6 +215,10 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
fclose($smtp_connection);
|
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 = $pdo->prepare("DELETE FROM `quarantine` WHERE id = :id");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':id' => $row['id']
|
':id' => $row['id']
|
||||||
@@ -453,6 +459,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
$mail->addAttachment($msg_tmpf, $row['qid'] . '.eml');
|
$mail->addAttachment($msg_tmpf, $row['qid'] . '.eml');
|
||||||
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
||||||
$mail->send();
|
$mail->send();
|
||||||
|
$released_msg = $mail->getSentMIMEMessage();
|
||||||
unlink($msg_tmpf);
|
unlink($msg_tmpf);
|
||||||
}
|
}
|
||||||
catch (phpmailerException $e) {
|
catch (phpmailerException $e) {
|
||||||
@@ -467,6 +474,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
elseif ($release_format == 'raw') {
|
elseif ($release_format == 'raw') {
|
||||||
$row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
|
$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
|
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||||
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
|
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
|
||||||
$postfix_talk = array(
|
$postfix_talk = array(
|
||||||
@@ -506,6 +514,11 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
fclose($smtp_connection);
|
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 = $pdo->prepare("DELETE FROM `quarantine` WHERE `id` = :id");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':id' => $id
|
':id' => $id
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ function init_db_schema()
|
|||||||
"gal" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
"gal" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"relay_all_recipients" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
"relay_all_recipients" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
"relay_unknown_only" => "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)",
|
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
||||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||||
@@ -1053,7 +1054,8 @@ function init_db_schema()
|
|||||||
"domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
"domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
|
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||||
),
|
),
|
||||||
"keys" => array(
|
"keys" => array(
|
||||||
"primary" => array(
|
"primary" => array(
|
||||||
@@ -1277,6 +1279,42 @@ function init_db_schema()
|
|||||||
),
|
),
|
||||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
"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(
|
"bcc_maps" => array(
|
||||||
"cols" => array(
|
"cols" => array(
|
||||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
@@ -2009,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`, '$.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_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`, '$.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
|
// admin attributes
|
||||||
$pdo->query("UPDATE `admin` SET `attributes` = '{}' WHERE `attributes` = '' OR `attributes` IS NULL;");
|
$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;");
|
$pdo->query("UPDATE `admin` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
|
||||||
@@ -2056,6 +2095,7 @@ function init_db_schema()
|
|||||||
"backupmx" => 0,
|
"backupmx" => 0,
|
||||||
"relay_all_recipients" => 0,
|
"relay_all_recipients" => 0,
|
||||||
"relay_unknown_only" => 0,
|
"relay_unknown_only" => 0,
|
||||||
|
"archive" => 0,
|
||||||
"dkim_selector" => "dkim",
|
"dkim_selector" => "dkim",
|
||||||
"key_size" => 2048,
|
"key_size" => 2048,
|
||||||
"max_quota_for_domain" => 10240 * 1048576,
|
"max_quota_for_domain" => 10240 * 1048576,
|
||||||
@@ -2073,6 +2113,7 @@ function init_db_schema()
|
|||||||
"rl_value" => "",
|
"rl_value" => "",
|
||||||
"force_pw_update" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_pw_update']),
|
"force_pw_update" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_pw_update']),
|
||||||
"force_tfa" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_tfa']),
|
"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']),
|
"sogo_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sogo_access']),
|
||||||
"active" => 1,
|
"active" => 1,
|
||||||
"tls_enforce_in" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['tls_enforce_in']),
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -205,6 +205,9 @@ $MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update'] = false;
|
|||||||
// Force 2FA enrollment at next login
|
// Force 2FA enrollment at next login
|
||||||
$MAILBOX_DEFAULT_ATTRIBUTES['force_tfa'] = false;
|
$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)
|
// 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;
|
$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');
|
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() {
|
function rspamd_pie_graph() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/api/v1/get/rspamd/actions',
|
url: '/api/v1/get/rspamd/actions',
|
||||||
@@ -1295,6 +1370,7 @@ jQuery(function($){
|
|||||||
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
|
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
|
||||||
onVisible("[id^=netfilter_log]", () => draw_netfilter_logs());
|
onVisible("[id^=netfilter_log]", () => draw_netfilter_logs());
|
||||||
onVisible("[id^=cron_log]", () => draw_cron_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_history]", () => draw_rspamd_history());
|
||||||
onVisible("[id^=rspamd_donut]", () => rspamd_pie_graph());
|
onVisible("[id^=rspamd_donut]", () => rspamd_pie_graph());
|
||||||
|
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ $(document).ready(function() {
|
|||||||
} else {
|
} else {
|
||||||
$('#addDomain_gal').prop('checked', false);
|
$('#addDomain_gal').prop('checked', false);
|
||||||
}
|
}
|
||||||
|
$('#addDomain_archive').prop('checked', template.archive == 1);
|
||||||
|
|
||||||
if (template.active == 1){
|
if (template.active == 1){
|
||||||
$('#addDomain_active').prop('checked', true);
|
$('#addDomain_active').prop('checked', true);
|
||||||
@@ -433,6 +434,7 @@ $(document).ready(function() {
|
|||||||
} else {
|
} else {
|
||||||
$('#force_tfa').prop('checked', false);
|
$('#force_tfa').prop('checked', false);
|
||||||
}
|
}
|
||||||
|
$('#addMailbox_archive').selectpicker('val', ['0', '1'].includes(String(template.archive)) ? String(template.archive) : 'inherit');
|
||||||
if (template.sogo_access == 1){
|
if (template.sogo_access == 1){
|
||||||
$('#sogo_access').prop('checked', true);
|
$('#sogo_access').prop('checked', true);
|
||||||
} else {
|
} 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&&'—');
|
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,
|
title: lang.action,
|
||||||
data: 'action',
|
data: 'action',
|
||||||
|
|||||||
@@ -858,6 +858,26 @@ if (isset($_GET['query'])) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "archive":
|
||||||
|
switch ($object) {
|
||||||
|
case "settings":
|
||||||
|
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getSettings());
|
||||||
|
break;
|
||||||
|
|
||||||
|
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(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "logs":
|
case "logs":
|
||||||
switch ($object) {
|
switch ($object) {
|
||||||
case "dovecot":
|
case "dovecot":
|
||||||
@@ -904,6 +924,11 @@ if (isset($_GET['query'])) {
|
|||||||
}
|
}
|
||||||
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
||||||
break;
|
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":
|
case "postfix":
|
||||||
// 0 is first record, so empty is fine
|
// 0 is first record, so empty is fine
|
||||||
if (isset($extra)) {
|
if (isset($extra)) {
|
||||||
@@ -1682,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' => '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' => '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],
|
['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') {
|
if($_SESSION['mailcow_cc_role'] === 'admin') {
|
||||||
@@ -1953,6 +1980,9 @@ if (isset($_GET['query'])) {
|
|||||||
case "mailbox-group":
|
case "mailbox-group":
|
||||||
process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr));
|
process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr));
|
||||||
break;
|
break;
|
||||||
|
case "archive":
|
||||||
|
process_edit_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->edit($attr));
|
||||||
|
break;
|
||||||
case "recipient_map":
|
case "recipient_map":
|
||||||
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
|
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"alias_domains": "Alias-Domains hinzufügen",
|
"alias_domains": "Alias-Domains hinzufügen",
|
||||||
"alias_external_goto": "Aliase mit externen Ziel-Domains erlauben",
|
"alias_external_goto": "Aliase mit externen Ziel-Domains erlauben",
|
||||||
"app_passwds": "App-Passwörter verwalten",
|
"app_passwds": "App-Passwörter verwalten",
|
||||||
|
"archive": "Archivierung von Domains und Mailboxen ändern",
|
||||||
"bcc_maps": "BCC-Maps",
|
"bcc_maps": "BCC-Maps",
|
||||||
"delimiter_action": "Delimiter-Aktionen (tags)",
|
"delimiter_action": "Delimiter-Aktionen (tags)",
|
||||||
"domain_desc": "Domainbeschreibung ändern",
|
"domain_desc": "Domainbeschreibung ändern",
|
||||||
@@ -138,6 +139,57 @@
|
|||||||
"app_links": "App-Links",
|
"app_links": "App-Links",
|
||||||
"app_name": "App-Name",
|
"app_name": "App-Name",
|
||||||
"apps_name": "\"mailcow Apps\" 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)",
|
"arrival_time": "Ankunftszeit (Serverzeit)",
|
||||||
"authed_user": "Auth. Benutzer",
|
"authed_user": "Auth. Benutzer",
|
||||||
"ays": "Soll der Vorgang wirklich ausgeführt werden?",
|
"ays": "Soll der Vorgang wirklich ausgeführt werden?",
|
||||||
@@ -516,6 +568,19 @@
|
|||||||
"api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden",
|
"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_name_empty": "App-Name darf nicht leer sein",
|
||||||
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
|
"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.",
|
"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_empty": "BCC-Ziel darf nicht leer sein",
|
||||||
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
|
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
|
||||||
@@ -780,6 +845,15 @@
|
|||||||
"app_name": "App-Name",
|
"app_name": "App-Name",
|
||||||
"app_passwd": "App-Passwörter",
|
"app_passwd": "App-Passwörter",
|
||||||
"app_passwd_protocols": "Zugelassene Protokolle für App-Passwort",
|
"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",
|
"backup_mx_options": "Relay-Optionen",
|
||||||
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
|
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
|
||||||
"client_id": "Client-ID",
|
"client_id": "Client-ID",
|
||||||
@@ -1061,6 +1135,7 @@
|
|||||||
"allow_from_smtp": "Nur folgende IPs für <b>SMTP</b> erlauben",
|
"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.",
|
"allow_from_smtp_info": "Leer lassen, um keine Prüfung durchzuführen.<br>IPv4- sowie IPv6-Adressen und Subnetzwerke.",
|
||||||
"allowed_protocols": "Erlaubte Protokolle",
|
"allowed_protocols": "Erlaubte Protokolle",
|
||||||
|
"archive": "Archiviert",
|
||||||
"backup_mx": "Relay-Domain",
|
"backup_mx": "Relay-Domain",
|
||||||
"bcc": "BCC",
|
"bcc": "BCC",
|
||||||
"bcc_destination": "BCC-Ziel",
|
"bcc_destination": "BCC-Ziel",
|
||||||
@@ -1305,6 +1380,9 @@
|
|||||||
"app_links": "Änderungen an App-Links wurden gespeichert",
|
"app_links": "Änderungen an App-Links wurden gespeichert",
|
||||||
"app_passwd_added": "App-Passwort wurde gespeichert",
|
"app_passwd_added": "App-Passwort wurde gespeichert",
|
||||||
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
|
"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_deleted": "BCC-Map-Einträge gelöscht: %s",
|
||||||
"bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
|
"bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
|
||||||
"bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
|
"bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
|
||||||
@@ -1718,6 +1796,7 @@
|
|||||||
"pushover_sound": "Ton"
|
"pushover_sound": "Ton"
|
||||||
},
|
},
|
||||||
"warning": {
|
"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",
|
"cannot_delete_self": "Kann derzeit eingeloggten Benutzer nicht entfernen",
|
||||||
"domain_added_sogo_failed": "Domain wurde hinzugefügt, aber SOGo konnte nicht neugestartet werden",
|
"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.",
|
"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_domains": "Add alias domains",
|
||||||
"alias_external_goto": "Allow aliases with external goto domains",
|
"alias_external_goto": "Allow aliases with external goto domains",
|
||||||
"app_passwds": "Manage app passwords",
|
"app_passwds": "Manage app passwords",
|
||||||
|
"archive": "Change archiving of domains and mailboxes",
|
||||||
"bcc_maps": "BCC maps",
|
"bcc_maps": "BCC maps",
|
||||||
"delimiter_action": "Delimiter action",
|
"delimiter_action": "Delimiter action",
|
||||||
"domain_desc": "Change domain description",
|
"domain_desc": "Change domain description",
|
||||||
@@ -142,6 +143,57 @@
|
|||||||
"app_links": "App links",
|
"app_links": "App links",
|
||||||
"app_name": "App name",
|
"app_name": "App name",
|
||||||
"apps_name": "\"mailcow Apps\" 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)",
|
"arrival_time": "Arrival time (server time)",
|
||||||
"authed_user": "Auth. user",
|
"authed_user": "Auth. user",
|
||||||
"ays": "Are you sure you want to proceed?",
|
"ays": "Are you sure you want to proceed?",
|
||||||
@@ -508,6 +560,19 @@
|
|||||||
"api_key_description_invalid": "API key description must not exceed 255 characters",
|
"api_key_description_invalid": "API key description must not exceed 255 characters",
|
||||||
"api_key_network_invalid": "Invalid IP address or network: %s",
|
"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",
|
"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_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_description_invalid": "The group description must not exceed 255 characters",
|
||||||
"group_grant_invalid": "Invalid share for mailbox %s",
|
"group_grant_invalid": "Invalid share for mailbox %s",
|
||||||
@@ -780,6 +845,15 @@
|
|||||||
"app_name": "App name",
|
"app_name": "App name",
|
||||||
"app_passwd": "App password",
|
"app_passwd": "App password",
|
||||||
"app_passwd_protocols": "Allowed protocols for 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",
|
"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.",
|
"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",
|
"client_id": "Client ID",
|
||||||
@@ -1062,6 +1136,7 @@
|
|||||||
"allow_from_smtp": "Only allow these IPs to use <b>SMTP</b>",
|
"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.",
|
"allow_from_smtp_info": "Leave empty to allow all senders.<br>IPv4/IPv6 addresses and networks.",
|
||||||
"allowed_protocols": "Allowed protocols",
|
"allowed_protocols": "Allowed protocols",
|
||||||
|
"archive": "Archived",
|
||||||
"backup_mx": "Relay domain",
|
"backup_mx": "Relay domain",
|
||||||
"bcc": "BCC",
|
"bcc": "BCC",
|
||||||
"bcc_destination": "BCC destination",
|
"bcc_destination": "BCC destination",
|
||||||
@@ -1312,6 +1387,9 @@
|
|||||||
"app_links": "Saved changes to app links",
|
"app_links": "Saved changes to app links",
|
||||||
"app_passwd_added": "Added new app password",
|
"app_passwd_added": "Added new app password",
|
||||||
"app_passwd_removed": "Removed app password ID %s",
|
"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_deleted": "BCC map entries deleted: %s",
|
||||||
"bcc_edited": "BCC map entry %s edited",
|
"bcc_edited": "BCC map entry %s edited",
|
||||||
"bcc_saved": "BCC map entry saved",
|
"bcc_saved": "BCC map entry saved",
|
||||||
@@ -1725,6 +1803,7 @@
|
|||||||
"years": "years"
|
"years": "years"
|
||||||
},
|
},
|
||||||
"warning": {
|
"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",
|
"cannot_delete_self": "Cannot delete logged in user",
|
||||||
"domain_added_sogo_failed": "Added domain but failed to restart SOGo, please check your server logs.",
|
"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",
|
"dovecot_restart_failed": "Dovecot failed to restart, please check the logs",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</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-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 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">
|
<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>
|
<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-oauth2.twig' %}
|
||||||
{% include 'admin/tab-config-rspamd.twig' %}
|
{% include 'admin/tab-config-rspamd.twig' %}
|
||||||
{% include 'admin/tab-routing.twig' %}
|
{% include 'admin/tab-routing.twig' %}
|
||||||
|
{% include 'admin/tab-archive.twig' %}
|
||||||
{% include 'admin/tab-config-dkim.twig' %}
|
{% include 'admin/tab-config-dkim.twig' %}
|
||||||
{% include 'admin/tab-config-fwdhosts.twig' %}
|
{% include 'admin/tab-config-fwdhosts.twig' %}
|
||||||
{% include 'admin/tab-config-f2b.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-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-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-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"><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"><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>
|
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.static_logs }}</span></li>
|
||||||
@@ -547,6 +548,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</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> <!-- /tab-content -->
|
||||||
</div> <!-- /col-md-12 -->
|
</div> <!-- /col-md-12 -->
|
||||||
</div> <!-- /row -->
|
</div> <!-- /row -->
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
<input type="hidden" value="0" name="backupmx">
|
<input type="hidden" value="0" name="backupmx">
|
||||||
<input type="hidden" value="0" name="gal">
|
<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_all_recipients">
|
||||||
<input type="hidden" value="0" name="relay_unknown_only">
|
<input type="hidden" value="0" name="relay_unknown_only">
|
||||||
|
|
||||||
@@ -70,6 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="row mb-2">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
<input type="hidden" value="0" name="backupmx">
|
<input type="hidden" value="0" name="backupmx">
|
||||||
<input type="hidden" value="0" name="gal">
|
<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_all_recipients">
|
||||||
<input type="hidden" value="0" name="relay_unknown_only">
|
<input type="hidden" value="0" name="relay_unknown_only">
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
@@ -133,6 +134,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<hr>
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
|||||||
@@ -114,6 +114,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2">ACL</label>
|
<label class="control-label col-sm-2">ACL</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
|||||||
@@ -331,6 +331,19 @@
|
|||||||
<small class="text-muted">{{ lang.edit.allow_from_smtp_info }}</small>
|
<small class="text-muted">{{ lang.edit.allow_from_smtp_info }}</small>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
<hr>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
var csrf_token = '{{ csrf_token }}';
|
var csrf_token = '{{ csrf_token }}';
|
||||||
var pagination_size = Math.trunc('{{ pagination_size }}');
|
var pagination_size = Math.trunc('{{ pagination_size }}');
|
||||||
var role = '{{ role }}';
|
var role = '{{ role }}';
|
||||||
|
var archive_enabled = {{ archive_enabled ? 1 : 0 }};
|
||||||
var is_dual = {{ is_dual }};
|
var is_dual = {{ is_dual }};
|
||||||
var ALLOW_ADMIN_EMAIL_LOGIN = {{ allow_admin_email_login }};
|
var ALLOW_ADMIN_EMAIL_LOGIN = {{ allow_admin_email_login }};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end text-sm-end" for="description">{{ lang.mailbox.template }}</label>
|
<label class="control-label col-sm-2 text-sm-end text-sm-end" for="description">{{ lang.mailbox.template }}</label>
|
||||||
<div class="col-sm-10">
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +154,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
@@ -360,6 +371,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
@@ -451,6 +473,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-horizontal" data-cached-form="true" data-id="add_domain" role="form">
|
<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="gal">
|
||||||
|
<input type="hidden" value="0" name="archive">
|
||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
<input type="hidden" value="0" name="backupmx">
|
<input type="hidden" value="0" name="backupmx">
|
||||||
<input type="hidden" value="0" name="relay_all_recipients">
|
<input type="hidden" value="0" name="relay_all_recipients">
|
||||||
@@ -525,6 +548,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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="row mb-4">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
@@ -664,6 +695,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="row mb-2">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
+3
-2
@@ -1,5 +1,5 @@
|
|||||||
# Version of the database schema
|
# Version of the database schema
|
||||||
x-db-version: &db_version DB_VERSION=02112026_1600
|
x-db-version: &db_version DB_VERSION=06112026_1800
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -348,7 +348,7 @@ services:
|
|||||||
- dovecot
|
- dovecot
|
||||||
|
|
||||||
postfix-mailcow:
|
postfix-mailcow:
|
||||||
image: ghcr.io/mailcow/postfix:3.10.12-1
|
image: ghcr.io/mailcow/postfix:3.10.12-2
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql-mailcow:
|
mysql-mailcow:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -375,6 +375,7 @@ services:
|
|||||||
- REDISPASS=${REDISPASS}
|
- REDISPASS=${REDISPASS}
|
||||||
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
|
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
|
||||||
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
|
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
|
||||||
|
- *db_version
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_BIND_SERVICE
|
- NET_BIND_SERVICE
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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