Compare commits

...

7 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
ad260aa81c Move mta_sts hidden input to top of form
Moved the mta_sts hidden input to the top of the form alongside other checkbox hidden inputs (active, backupmx, gal, etc.) to ensure consistent form serialization behavior. This matches the pattern used for all other checkbox fields in the template edit form.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-15 16:05:08 +00:00
copilot-swe-agent[bot]
ea8a383c2c Fix domain template edit to preserve existing MTA-STS values
The edit function now properly preserves existing attribute values from the database when updating a domain template, instead of resetting to hardcoded defaults. This applies to all template attributes including the newly added MTA-STS fields.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-15 15:51:44 +00:00
copilot-swe-agent[bot]
96ca1ed693 Fix duplicate CSS classes in MTA-STS template fields
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-15 15:42:43 +00:00
copilot-swe-agent[bot]
f4afd19e99 Add MTA-STS fields to add domain template modal
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-15 15:40:49 +00:00
copilot-swe-agent[bot]
42a9e65f28 Add MTA-STS support to domain templates
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
2025-12-15 15:39:49 +00:00
copilot-swe-agent[bot]
4e164c9ef9 Initial plan 2025-12-15 15:31:25 +00:00
Copilot
038b2efb75 Add MTA-STS support for alias domains (#6972)
* Initial plan

* Add MTA-STS support for alias domains

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Improve domain normalization and code style in mta-sts.php

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add error handling for idn_to_ascii in mta-sts.php

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add database error handling for alias domain query

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* Add ACME certificate support for MTA-STS on alias domains

Query alias_domain table to find aliases with MTA-STS enabled target domains and request certificates for mta-sts.<alias-domain> subdomains.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

* compose: bump image tag to 1.95

* Add MTA-STS DNS records display for alias domains in UI

When viewing an alias domain's DNS diagnostics, check if the target domain has MTA-STS enabled and display the required DNS records for the alias domain.

Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DerLinkman <62480600+DerLinkman@users.noreply.github.com>
Co-authored-by: DerLinkman <niklas.meyer@servercow.de>
2025-12-15 16:29:21 +01:00
8 changed files with 177 additions and 18 deletions

View File

@@ -246,6 +246,25 @@ while true; do
done
VALIDATED_CONFIG_DOMAINS+=("${VALIDATED_CONFIG_DOMAINS_SUBDOMAINS[*]}")
done
# Fetch alias domains where target domain has MTA-STS enabled
if [[ ${AUTODISCOVER_SAN} == "y" ]]; then
SQL_ALIAS_DOMAINS=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT ad.alias_domain FROM alias_domain ad INNER JOIN mta_sts m ON ad.target_domain = m.domain WHERE ad.active = 1 AND m.active = 1" -Bs)
if [[ $? -eq 0 ]]; then
while read alias_domain; do
if [[ -z "${alias_domain}" ]]; then
# ignore empty lines
continue
fi
# Only add mta-sts subdomain for alias domains
if [[ "mta-sts.${alias_domain}" != "${MAILCOW_HOSTNAME}" ]]; then
if check_domain "mta-sts.${alias_domain}"; then
VALIDATED_CONFIG_DOMAINS+=("mta-sts.${alias_domain}")
fi
fi
done <<< "${SQL_ALIAS_DOMAINS}"
fi
fi
fi
if check_domain ${MAILCOW_HOSTNAME}; then

View File

@@ -129,7 +129,16 @@ if (isset($_SESSION['mailcow_cc_role']) && ($_SESSION['mailcow_cc_role'] == "adm
);
}
$mta_sts = mailbox('get', 'mta_sts', $domain);
// Check if domain is an alias domain and get target domain's MTA-STS
$alias_domain_details = mailbox('get', 'alias_domain_details', $domain);
$mta_sts_domain = $domain;
if ($alias_domain_details !== false && !empty($alias_domain_details['target_domain'])) {
// This is an alias domain, check target domain for MTA-STS
$mta_sts_domain = $alias_domain_details['target_domain'];
}
$mta_sts = mailbox('get', 'mta_sts', $mta_sts_domain);
if (count($mta_sts) > 0 && $mta_sts['active'] == 1) {
if (!in_array($domain, $alias_domains)) {
$records[] = array(

View File

@@ -664,6 +664,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
dkim('add', array('key_size' => $_data['key_size'], 'dkim_selector' => $_data['dkim_selector'], 'domains' => $domain));
}
}
// Create MTA-STS settings from template if enabled
if (!empty($DOMAIN_DEFAULT_ATTRIBUTES['mta_sts']) && $DOMAIN_DEFAULT_ATTRIBUTES['mta_sts'] == 1) {
$mta_sts_data = array(
'domain' => $domain,
'version' => $DOMAIN_DEFAULT_ATTRIBUTES['mta_sts_version'],
'mode' => $DOMAIN_DEFAULT_ATTRIBUTES['mta_sts_mode'],
'max_age' => $DOMAIN_DEFAULT_ATTRIBUTES['mta_sts_max_age'],
'mx' => $DOMAIN_DEFAULT_ATTRIBUTES['mta_sts_mx'],
'active' => 1
);
mailbox('add', 'mta_sts', $mta_sts_data);
}
if (!empty($restart_sogo)) {
$restart_response = json_decode(docker('post', 'sogo-mailcow', 'restart'), true);
if ($restart_response['type'] == "success") {
@@ -1648,6 +1660,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
$attr['mta_sts'] = (isset($_data['mta_sts'])) ? intval($_data['mta_sts']) : 0;
$attr['mta_sts_version'] = (isset($_data['mta_sts_version'])) ? $_data['mta_sts_version'] : 'stsv1';
$attr['mta_sts_mode'] = (isset($_data['mta_sts_mode'])) ? $_data['mta_sts_mode'] : 'enforce';
$attr['mta_sts_max_age'] = (isset($_data['mta_sts_max_age'])) ? intval($_data['mta_sts_max_age']) : 604800;
$attr['mta_sts_mx'] = (isset($_data['mta_sts_mx'])) ? $_data['mta_sts_mx'] : '';
// save template
$stmt = $pdo->prepare("INSERT INTO `templates` (`type`, `template`, `attributes`)
@@ -2984,21 +3001,26 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
}
// check attributes
$attr = array();
$attr['tags'] = (isset($_data['tags'])) ? $_data['tags'] : array();
$attr['max_num_aliases_for_domain'] = (isset($_data['max_num_aliases_for_domain'])) ? intval($_data['max_num_aliases_for_domain']) : 0;
$attr['max_num_mboxes_for_domain'] = (isset($_data['max_num_mboxes_for_domain'])) ? intval($_data['max_num_mboxes_for_domain']) : 0;
$attr['def_quota_for_mbox'] = (isset($_data['def_quota_for_mbox'])) ? intval($_data['def_quota_for_mbox']) * 1048576 : 0;
$attr['max_quota_for_mbox'] = (isset($_data['max_quota_for_mbox'])) ? intval($_data['max_quota_for_mbox']) * 1048576 : 0;
$attr['max_quota_for_domain'] = (isset($_data['max_quota_for_domain'])) ? intval($_data['max_quota_for_domain']) * 1048576 : 0;
$attr['rl_frame'] = (!empty($_data['rl_frame'])) ? $_data['rl_frame'] : "s";
$attr['rl_value'] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
$attr['active'] = isset($_data['active']) ? intval($_data['active']) : 1;
$attr['gal'] = (isset($_data['gal'])) ? intval($_data['gal']) : 1;
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
$attr['tags'] = (isset($_data['tags'])) ? $_data['tags'] : (isset($is_now['attributes']['tags']) ? $is_now['attributes']['tags'] : array());
$attr['max_num_aliases_for_domain'] = (isset($_data['max_num_aliases_for_domain'])) ? intval($_data['max_num_aliases_for_domain']) : (isset($is_now['attributes']['max_num_aliases_for_domain']) ? $is_now['attributes']['max_num_aliases_for_domain'] : 0);
$attr['max_num_mboxes_for_domain'] = (isset($_data['max_num_mboxes_for_domain'])) ? intval($_data['max_num_mboxes_for_domain']) : (isset($is_now['attributes']['max_num_mboxes_for_domain']) ? $is_now['attributes']['max_num_mboxes_for_domain'] : 0);
$attr['def_quota_for_mbox'] = (isset($_data['def_quota_for_mbox'])) ? intval($_data['def_quota_for_mbox']) * 1048576 : (isset($is_now['attributes']['def_quota_for_mbox']) ? $is_now['attributes']['def_quota_for_mbox'] : 0);
$attr['max_quota_for_mbox'] = (isset($_data['max_quota_for_mbox'])) ? intval($_data['max_quota_for_mbox']) * 1048576 : (isset($is_now['attributes']['max_quota_for_mbox']) ? $is_now['attributes']['max_quota_for_mbox'] : 0);
$attr['max_quota_for_domain'] = (isset($_data['max_quota_for_domain'])) ? intval($_data['max_quota_for_domain']) * 1048576 : (isset($is_now['attributes']['max_quota_for_domain']) ? $is_now['attributes']['max_quota_for_domain'] : 0);
$attr['rl_frame'] = (!empty($_data['rl_frame'])) ? $_data['rl_frame'] : (isset($is_now['attributes']['rl_frame']) ? $is_now['attributes']['rl_frame'] : "s");
$attr['rl_value'] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : (isset($is_now['attributes']['rl_value']) ? $is_now['attributes']['rl_value'] : "");
$attr['active'] = isset($_data['active']) ? intval($_data['active']) : (isset($is_now['attributes']['active']) ? $is_now['attributes']['active'] : 1);
$attr['gal'] = (isset($_data['gal'])) ? intval($_data['gal']) : (isset($is_now['attributes']['gal']) ? $is_now['attributes']['gal'] : 1);
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : (isset($is_now['attributes']['backupmx']) ? $is_now['attributes']['backupmx'] : 0);
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : (isset($is_now['attributes']['relay_all_recipients']) ? $is_now['attributes']['relay_all_recipients'] : 0);
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : (isset($is_now['attributes']['relay_unknown_only']) ? $is_now['attributes']['relay_unknown_only'] : 0);
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : (isset($is_now['attributes']['dkim_selector']) ? $is_now['attributes']['dkim_selector'] : "dkim");
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : (isset($is_now['attributes']['key_size']) ? $is_now['attributes']['key_size'] : 2048);
$attr['mta_sts'] = (isset($_data['mta_sts'])) ? intval($_data['mta_sts']) : (isset($is_now['attributes']['mta_sts']) ? $is_now['attributes']['mta_sts'] : 0);
$attr['mta_sts_version'] = (isset($_data['mta_sts_version'])) ? $_data['mta_sts_version'] : (isset($is_now['attributes']['mta_sts_version']) ? $is_now['attributes']['mta_sts_version'] : 'stsv1');
$attr['mta_sts_mode'] = (isset($_data['mta_sts_mode'])) ? $_data['mta_sts_mode'] : (isset($is_now['attributes']['mta_sts_mode']) ? $is_now['attributes']['mta_sts_mode'] : 'enforce');
$attr['mta_sts_max_age'] = (isset($_data['mta_sts_max_age'])) ? intval($_data['mta_sts_max_age']) : (isset($is_now['attributes']['mta_sts_max_age']) ? $is_now['attributes']['mta_sts_max_age'] : 604800);
$attr['mta_sts_mx'] = (isset($_data['mta_sts_mx'])) ? $_data['mta_sts_mx'] : (isset($is_now['attributes']['mta_sts_mx']) ? $is_now['attributes']['mta_sts_mx'] : '');
// update template
$stmt = $pdo->prepare("UPDATE `templates`

View File

@@ -723,6 +723,8 @@
"mta_sts_mx": "MX server",
"mta_sts_mx_info": "Allows sending only to explicitly listed mail server hostnames; the sending MTA checks if the DNS MX hostname matches the policy list, and only allows delivery with a valid TLS certificate (guards against MITM).",
"mta_sts_mx_notice": "Multiple MX servers can be specified (separated by commas).",
"mta_sts_enable": "Enable MTA-STS",
"mta_sts_template_info": "When enabled, MTA-STS will be automatically configured for all domains created with this template.",
"multiple_bookings": "Multiple bookings",
"none_inherit": "None / Inherit",
"nexthop": "Next hop",

View File

@@ -7,7 +7,30 @@ if (!isset($_SERVER['HTTP_HOST']) || strpos($_SERVER['HTTP_HOST'], 'mta-sts.') !
}
$host = preg_replace('/:[0-9]+$/', '', $_SERVER['HTTP_HOST']);
$domain = str_replace('mta-sts.', '', $host);
$domain = idn_to_ascii(strtolower(str_replace('mta-sts.', '', $host)), 0, INTL_IDNA_VARIANT_UTS46);
// Validate domain or return 404 on error
if ($domain === false || empty($domain)) {
http_response_code(404);
exit;
}
// Check if domain is an alias domain and resolve to target domain
try {
$stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain");
$stmt->execute(array(':domain' => $domain));
$alias_row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($alias_row !== false && !empty($alias_row['target_domain'])) {
// This is an alias domain, use the target domain for MTA-STS lookup
$domain = $alias_row['target_domain'];
}
} catch (PDOException $e) {
// On database error, return 404
http_response_code(404);
exit;
}
$mta_sts = mailbox('get', 'mta_sts', $domain);
if (count($mta_sts) == 0 ||

View File

@@ -10,6 +10,7 @@
<input type="hidden" value="0" name="gal">
<input type="hidden" value="0" name="relay_all_recipients">
<input type="hidden" value="0" name="relay_unknown_only">
<input type="hidden" value="0" name="mta_sts">
{% if mailcow_cc_role == 'admin' %}
<div class="row mb-4">
@@ -124,6 +125,47 @@
</div>
</div>
<hr>
<div class="row mb-4">
<label class="control-label col-sm-2">{{ lang.edit.mta_sts }}</label>
<div class="col-sm-10">
<div class="form-check mb-3">
<label><input type="checkbox" class="form-check-input" value="1" name="mta_sts"{% if template.attributes.mta_sts == '1' %} checked{% endif %}> {{ lang.edit.mta_sts_enable }}</label>
<p><small class="text-muted">{{ lang.edit.mta_sts_template_info|raw }}</small></p>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3" for="mta_sts_version">{{ lang.edit.mta_sts_version }}</label>
<div class="col-sm-9">
<select data-style="btn btn-light" class="form-control" name="mta_sts_version">
<option value="stsv1"{% if template.attributes.mta_sts_version == 'stsv1' %} selected{% endif %}>STSv1</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3" for="mta_sts_mode">{{ lang.edit.mta_sts_mode }}</label>
<div class="col-sm-9">
<select data-style="btn btn-light" class="form-control" name="mta_sts_mode">
<option value="enforce"{% if template.attributes.mta_sts_mode == 'enforce' %} selected{% endif %}>enforce</option>
<option value="testing"{% if template.attributes.mta_sts_mode == 'testing' %} selected{% endif %}>testing</option>
<option value="none"{% if template.attributes.mta_sts_mode == 'none' %} selected{% endif %}>none</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3" for="mta_sts_max_age">{{ lang.edit.mta_sts_max_age }}</label>
<div class="col-sm-9">
<input type="number" class="form-control" name="mta_sts_max_age" value="{{ template.attributes.mta_sts_max_age|default('604800') }}">
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3" for="mta_sts_mx">{{ lang.edit.mta_sts_mx }}</label>
<div class="col-sm-9">
<textarea autocorrect="off" autocapitalize="none" class="form-control" rows="3" name="mta_sts_mx">{{ template.attributes.mta_sts_mx }}</textarea>
<small class="text-muted">{{ lang.edit.mta_sts_mx_notice|raw }}</small>
</div>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="offset-sm-2 col-sm-10">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="editdomain_template" data-item="{{ template.id }}" data-api-url='edit/domain/template' data-api-attr='{}' href="#">{{ lang.admin.save }}</button>

View File

@@ -688,6 +688,48 @@
</div>
</div>
<hr>
<div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end">{{ lang.edit.mta_sts }}</label>
<div class="col-sm-10">
<div class="form-check mb-3">
<input type="hidden" value="0" name="mta_sts">
<label><input type="checkbox" class="form-check-input" value="1" name="mta_sts"> {{ lang.edit.mta_sts_enable }}</label>
<p><small class="text-muted">{{ lang.edit.mta_sts_template_info|raw }}</small></p>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3 text-sm-end" for="mta_sts_version">{{ lang.edit.mta_sts_version }}</label>
<div class="col-sm-9">
<select data-style="btn btn-light" class="form-control" name="mta_sts_version">
<option value="stsv1" selected>STSv1</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3 text-sm-end" for="mta_sts_mode">{{ lang.edit.mta_sts_mode }}</label>
<div class="col-sm-9">
<select data-style="btn btn-light" class="form-control" name="mta_sts_mode">
<option value="enforce" selected>enforce</option>
<option value="testing">testing</option>
<option value="none">none</option>
</select>
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3 text-sm-end" for="mta_sts_max_age">{{ lang.edit.mta_sts_max_age }}</label>
<div class="col-sm-9">
<input type="number" class="form-control" name="mta_sts_max_age" value="604800">
</div>
</div>
<div class="row mb-2">
<label class="control-label col-sm-3 text-sm-end" for="mta_sts_mx">{{ lang.edit.mta_sts_mx }}</label>
<div class="col-sm-9">
<textarea autocorrect="off" autocapitalize="none" class="form-control" rows="3" name="mta_sts_mx"></textarea>
<small class="text-muted">{{ lang.edit.mta_sts_mx_notice|raw }}</small>
</div>
</div>
</div>
</div>
<hr>
<div class="row">
<div class="offset-sm-2 col-sm-10">
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="adddomain_template" data-item="{{ domain }}" data-api-url='add/domain/template' data-api-attr='{}' href="#">{{ lang.admin.add }}</button>

View File

@@ -465,7 +465,7 @@ services:
condition: service_started
unbound-mailcow:
condition: service_healthy
image: ghcr.io/mailcow/acme:1.94
image: ghcr.io/mailcow/acme:1.95
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment: