fix(web-app-stalwart): authenticate bots with an app password under SSO

Under SSO the role tried to let applications relay without SMTP AUTH by
widening the relay rule to the private networks. The rule compared the
peer address to the text of a CIDR, which never matches, so an
application could reach local mailboxes but got "550 5.1.2 Relay not
allowed" for every external recipient. Repairing the comparison would
have opened a relay, because published ports, Tor and the swarm mesh all
hand outside clients an address inside those networks.

Stalwart checks app passwords before it asks the external directory, so
a bot can authenticate while the directory is OIDC. With SSO enabled the
role now creates one app password per bot, acting for that account as
the recovery admin, and stores it in the token store the SMTP password
is already read from. A bot gets a new one when its entry is deleted in
Stalwart or its token is removed.

The relay rule is back to authenticated sessions only. The lookup loses
its SSO relay branch and the role its submission_via_relay flag, so an
SSO deployment submits on 465 with AUTH like every other one.

The role's CLI test now fails when an unauthenticated session is given
an external recipient, or when the bot credential is refused one.

Closes #793

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-08 08:57:22 +02:00
1 parent ebae9c537c
commit 08ef66d8de
16 files changed
+168 -156

No files matched your search

+3 -35
View File
@@ -166,9 +166,6 @@ class LookupModule(LookupBase):
return _as_bool(variables.get("TLS_ENABLED"))
if short_key == "port":
external = _as_bool(resolved.get("external"))
# SSO relay: port 25 is the only listener not requiring SMTP AUTH.
if self._provider_uses_sso_relay(variables):
return 25
if not external:
return 25
ports = self._provider_ports(variables)
@@ -179,8 +176,7 @@ class LookupModule(LookupBase):
# binds the implicit-TLS client ports plus the MX, so it advertises
# no `submission`; fall back to the MX port rather than a listener
# nothing answers on (`auth` drops with it -- an MX takes local
# recipients without SMTP AUTH, the same property the SSO relay
# above relies on).
# recipients without SMTP AUTH).
return ports.get("submission") or ports.get("smtp", 25)
if short_key == "host":
env = resolved.get("environment")
@@ -200,8 +196,6 @@ class LookupModule(LookupBase):
env = resolved.get("environment")
if env in ("external_container", "localhost"):
return False
if self._provider_uses_sso_relay(variables):
return False
# The MX listener offers no SMTP AUTH: when `port` fell back to it
# because the provider binds no plaintext submission, announcing
# AUTH aborts msmtp with EX_UNAVAILABLE before it ever sends.
@@ -216,11 +210,7 @@ class LookupModule(LookupBase):
return "off"
return "on" if _as_bool(resolved.get("tls")) else "plain"
if short_key == "start_tls":
if str(resolved.get("host") or "").lower().endswith(
".onion"
) or self._provider_onion_primary(variables):
return False
return self._provider_uses_sso_relay(variables)
return False
if short_key == "smtp":
return True
if short_key == "from":
@@ -255,8 +245,7 @@ class LookupModule(LookupBase):
def _provider_services(self, variables: dict[str, Any]) -> dict[str, Any]:
"""The active provider's ``services`` block, or ``{}`` when it cannot be
resolved. Both the relay probe and the port probe read the provider's
own self-declaration, so the applications lookup is done once here."""
resolved. The port probe reads the provider's own self-declaration."""
apps = ApplicationsLookup()
apps._templar = getattr(self, "_templar", None)
forwarded = {
@@ -299,27 +288,6 @@ class LookupModule(LookupBase):
continue
return out
def _provider_uses_sso_relay(self, variables: dict[str, Any]) -> bool:
"""True when the active provider self-declares SSO relay (via
``services.sso.oidc.submission_via_relay``) and Keycloak is deployed."""
group_names = variables.get("group_names") or []
if "web-app-keycloak" not in group_names:
return False
services = self._provider_services(variables)
sso = services.get("sso") or {} if isinstance(services, dict) else {}
oidc = sso.get("oidc") or {} if isinstance(sso, dict) else {}
if not isinstance(oidc, dict):
return False
# A variant/inventory can pin the provider's sso.enabled to a literal
# false while submission_via_relay stays true in the role defaults —
# then the provider keeps password auth and never widens allowRelaying,
# so relay mode must be off. Untemplated Jinja (the role default gates
# on group_names) falls through to the Keycloak check above.
enabled = sso.get("enabled") if isinstance(sso, dict) else None
if enabled is not None and "{{" not in str(enabled) and not _as_bool(enabled):
return False
return _as_bool(oidc.get("submission_via_relay"))
def _provider_onion_primary(self, variables: dict[str, Any]) -> bool:
"""True when the provider's own host serves it onion-first.
+18 -14
View File
@@ -57,7 +57,7 @@ flowchart LR
STALWART -- "OIDC directory<br/>(token validation)" --> KC
MAIL_VHOST -. "SPA login: PKCE<br/>(stalwart-webui client)" .-> KC
WEBMAIL_VHOST -. "OAuth login" .-> KC
APPS -- "STARTTLS :25, no AUTH<br/>(SSO relay, trusted networks)" --> STALWART
APPS -- "SMTPS :465, AUTH<br/>(bot credential from the token store)" --> STALWART
WORLD <-- "public mail ports<br/>(only when active MAIL_PROVIDER)" --> STALWART
```
@@ -151,19 +151,23 @@ delegates **interactive** authentication to Keycloak:
**Design constraint (validated against the live JMAP schema):** Stalwart's
authentication directory is *Internal XOR one external directory* — there is no
chaining or fallback. Enabling SSO therefore **disables password submission**
for every account, including the machine `no-reply` account. To keep outbound
notifications working, the role:
chaining or fallback. Enabling SSO therefore **disables the account password**
for every account, including the machine `no-reply` account. App passwords are
checked before the directory and keep working, so with SSO enabled the role:
1. Widens `x:MtaStageRcpt.allowRelaying` to trust the internal Docker networks
(`STALWART_TRUSTED_NETWORKS`), so the bot relays without SMTP AUTH; and
2. Self-declares `services.sso.oidc.submission_via_relay: true`, which makes
[`plugins/lookup/email.py`](../../plugins/lookup/email.py) switch the
`no-reply` client to unauthenticated STARTTLS relay on port 25.
1. Creates one app password per bot account, acting for that account as the
recovery admin (`tasks/04_manage_user/app_password.yml`), and
2. Stores it in the token store, where
[`plugins/lookup/email.py`](../../plugins/lookup/email.py) reads the SMTP
password of the `no-reply` client.
With SSO disabled the role uses the Internal directory and password submission,
exactly as before. Mailu keeps password submission in both modes and does not
set `submission_via_relay`.
A bot gets a new app password when its entry named
`STALWART_BOT_APP_PASSWORD_DESCRIPTION` is deleted in Stalwart or its token is
removed from the token store. Relaying stays limited to authenticated sessions
in both modes (`STALWART_RELAY_RULE`).
With SSO disabled the role uses the Internal directory and the bot's derived
account password.
## Calendar & Contacts (CalDAV / CardDAV / WebDAV)
@@ -195,8 +199,8 @@ account (or the Keycloak SSO token when SSO is enabled).
- **`config.json` is bootstrap-only** (data store selection); all other
configuration lives in the database and is provisioned over JMAP
(`templates/jmap/*.json.j2`).
- **Under SSO, trusted internal networks relay without SMTP AUTH** — the
no-reply bot has no password once the auth directory is OIDC.
- **Only authenticated sessions relay, with or without SSO.** Under SSO a bot
authenticates with an app password.
## Features
@@ -10,7 +10,10 @@ speaks IMAP through imaplib.
probe_mail.py send <host> <from> <rcpt> <eml-file>
probe_mail.py find <host> <user> <password> <subject>
probe_mail.py relay <host> <sender> <password>
`relay` exits 0 when an unauthenticated session is refused an external recipient
and the same recipient is accepted once `<sender>` has authenticated, 1 otherwise.
`find` exits 0 when the subject is present in INBOX or Junk, 1 when it is not.
Both mailboxes are searched because a test domain publishes no mail-auth DNS, so
a correctly delivered message is legitimately filed under Junk. Certificate
@@ -32,10 +35,19 @@ import sys
from pathlib import Path
SMTP_PORT = 25
SMTPS_PORT = 465
IMAPS_PORT = 993
TIMEOUT = 60
MAILBOXES = ("INBOX", "Junk Mail")
EXTERNAL_RECIPIENT = "relay-probe@example.org"
def unverified_context() -> ssl.SSLContext:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
return context
def send(host: str, sender: str, recipient: str, eml_path: str) -> int:
@@ -45,12 +57,46 @@ def send(host: str, sender: str, recipient: str, eml_path: str) -> int:
return 0
def find(host: str, user: str, password: str, subject: str) -> int:
context = ssl.create_default_context()
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
def relay(host: str, sender: str, password: str) -> int:
with smtplib.SMTP(host, SMTP_PORT, timeout=TIMEOUT) as smtp:
smtp.ehlo()
smtp.mail(sender)
anonymous, _ = smtp.rcpt(EXTERNAL_RECIPIENT)
if anonymous < 500:
print(
f"open relay: an unauthenticated session got {anonymous} "
f"for {EXTERNAL_RECIPIENT}",
file=sys.stderr,
)
return 1
imap = imaplib.IMAP4_SSL(host, IMAPS_PORT, ssl_context=context, timeout=TIMEOUT)
with smtplib.SMTP_SSL(
host, SMTPS_PORT, timeout=TIMEOUT, context=unverified_context()
) as smtp:
try:
smtp.login(sender, password)
except smtplib.SMTPAuthenticationError as error:
print(
f"submission refused: {sender} cannot authenticate ({error.smtp_code})",
file=sys.stderr,
)
return 1
smtp.mail(sender)
authenticated, _ = smtp.rcpt(EXTERNAL_RECIPIENT)
if authenticated != 250:
print(
f"submission refused: {sender} authenticated but got {authenticated} "
f"for {EXTERNAL_RECIPIENT}",
file=sys.stderr,
)
return 1
return 0
def find(host: str, user: str, password: str, subject: str) -> int:
imap = imaplib.IMAP4_SSL(
host, IMAPS_PORT, ssl_context=unverified_context(), timeout=TIMEOUT
)
imap._encoding = "utf-8"
try:
imap.login(user, password)
@@ -76,6 +122,8 @@ def main(argv: list[str]) -> int:
return send(*args)
if action == "find" and len(args) == 4:
return find(*args)
if action == "relay" and len(args) == 3:
return relay(*args)
print(__doc__, file=sys.stderr)
return 2
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
: "${PYTHON_BIN:?missing PYTHON_BIN}"
: "${MAIL_HOST:?missing MAIL_HOST}"
: "${NO_REPLY_EMAIL:?missing NO_REPLY_EMAIL}"
: "${NO_REPLY_SMTP_PASSWORD:?missing NO_REPLY_SMTP_PASSWORD}"
"${PYTHON_BIN}" "${SCRIPT_DIR}/probe_mail.py" relay \
"${MAIL_HOST}" "${NO_REPLY_EMAIL}" "${NO_REPLY_SMTP_PASSWORD}"
echo "[relay] unauthenticated relay refused, ${NO_REPLY_EMAIL} submits authenticated"
@@ -4,4 +4,5 @@
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
bash "${SCRIPT_DIR}/relay.sh"
bash "${SCRIPT_DIR}/verify_migration.sh"
-1
View File
@@ -8,7 +8,6 @@ sso:
oidc:
email_by_username: true
enable_user_creation: true
submission_via_relay: true
clients:
webui: stalwart-webui
webmail: stalwart-webmail
@@ -46,4 +46,14 @@
sys_token_store_user_key: "{{ stalwart_user_key }}"
sys_token_store_app: "{{ application_id }}"
sys_token_store_token: "{{ stalwart_user_secret }}"
when: "'bot' in stalwart_user_roles"
when:
- "'bot' in stalwart_user_roles"
- not (STALWART_SSO_ENABLED | bool)
- name: "🔑 Keep an app password for bot '{{ stalwart_user_name }}' under SSO"
include_tasks: 04_manage_user/app_password.yml
vars:
stalwart_jmap_act_as: "{{ stalwart_user_name }}@{{ STALWART_DOMAIN }}"
when:
- "'bot' in stalwart_user_roles"
- STALWART_SSO_ENABLED | bool
@@ -0,0 +1,52 @@
---
# Param: stalwart_jmap_act_as - address of the bot account, every call below runs as that account.
- name: "📖 List the app passwords of '{{ stalwart_user_name }}'"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:AppPassword/get', {'ids': none}, 'c0']]} | to_json }}
- name: "🔍 Decide whether '{{ stalwart_user_name }}' needs a new app password"
ansible.builtin.set_fact:
stalwart_app_password_ids: "{{ stalwart_app_password_owned }}"
stalwart_app_password_mint: >-
{{ (stalwart_app_password_owned | length != 1)
or (stalwart_app_password_stored | length == 0)
or (stalwart_app_password_stored == stalwart_user_secret) }}
vars:
stalwart_app_password_owned: >-
{{ stalwart_jmap.methodResponses[0][1].list
| selectattr('description', 'equalto', STALWART_BOT_APP_PASSWORD_DESCRIPTION)
| map(attribute='id') | list }}
stalwart_app_password_stored: "{{ lookup('users', stalwart_user_key, {}).get('tokens', {}).get(application_id, '') }}"
- name: "🗑️ Drop the stale app passwords of '{{ stalwart_user_name }}'"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:AppPassword/set', {'destroy': stalwart_app_password_ids}, 'c0']]} | to_json }}
when:
- stalwart_app_password_mint | bool
- stalwart_app_password_ids | length > 0
- name: "🔑 Mint the app password of '{{ stalwart_user_name }}'"
include_tasks: "{{ role_path }}/tasks/jmap_call.yml"
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:AppPassword/set', {'create': {'p': {
'description': STALWART_BOT_APP_PASSWORD_DESCRIPTION
}}}, 'c0']]} | to_json }}
when: stalwart_app_password_mint | bool
- name: "💾 Persist the app password of '{{ stalwart_user_name }}'"
ansible.builtin.include_role:
name: sys-token-store
tasks_from: write.yml
vars:
sys_token_store_user_key: "{{ stalwart_user_key }}"
sys_token_store_app: "{{ application_id }}"
sys_token_store_token: "{{ stalwart_jmap.methodResponses[0][1].created.p.secret }}"
when: stalwart_app_password_mint | bool
+2 -17
View File
@@ -86,28 +86,13 @@
stalwart_jmap_body: "{{ lookup('template', 'jmap/authentication/update.json.j2') }}"
when: stalwart_auth_exists | bool
- name: "⚙️ Build the trusted-network relay expression"
ansible.builtin.set_fact:
stalwart_relay_expr: >-
!is_empty(authenticated_as){% for net in STALWART_TRUSTED_NETWORKS %} || remote_ip == '{{ net }}'{% endfor %}
- name: "📖 Read the RCPT stage singleton"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/get.json.j2') }}"
- name: "🔧 Record whether the RCPT stage singleton exists"
ansible.builtin.set_fact:
stalwart_rcpt_exists: "{{ (stalwart_jmap.methodResponses[0][1].list | length) > 0 }}"
- name: "📁 Create the RCPT stage with trusted-network relay"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/create.json.j2') }}"
when: not (stalwart_rcpt_exists | bool)
- name: "🔄 Update the RCPT stage with trusted-network relay"
- name: "🔒 Restrict relaying to authenticated sessions"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: "{{ lookup('template', 'jmap/rcpt/update.json.j2') }}"
when: stalwart_rcpt_exists | bool
when: (stalwart_jmap.methodResponses[0][1].list | length) > 0
+2 -1
View File
@@ -1,6 +1,7 @@
---
# Reusable management JMAP call: in `stalwart_jmap_body` (JSON), out `stalwart_jmap`.
# Param: stalwart_jmap_tolerate_rejected - true when the caller reads a set-level rejection itself (e.g. alreadyExists).
# Param: stalwart_jmap_act_as - account address the recovery admin acts for; unset runs the call as the admin itself.
- name: "🔧 Stalwart JMAP call"
ansible.builtin.shell:
cmd: >-
@@ -9,7 +10,7 @@
-H "Content-Type:application/json"
{{ STALWART_JMAP_URL }}
stdin: |-
user = {{ (STALWART_ADMIN_USER ~ ':' ~ STALWART_ADMIN_PASSWORD) | to_json(ensure_ascii=False) }}
user = {{ ((stalwart_jmap_act_as ~ '%' if stalwart_jmap_act_as is defined else '') ~ STALWART_ADMIN_USER ~ ':' ~ STALWART_ADMIN_PASSWORD) | to_json(ensure_ascii=False) }}
data-binary = {{ stalwart_jmap_body | to_json(ensure_ascii=False) }}
register: stalwart_jmap_raw
changed_when: false
@@ -1,4 +0,0 @@
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [["x:MtaStageRcpt/set", {"create": {"r": {"allowRelaying": {"else": {{ stalwart_relay_expr | trim | to_json }} }}}}, "c0"]]
}
@@ -1,4 +1,4 @@
{
"using": ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"],
"methodCalls": [["x:MtaStageRcpt/set", {"update": {"singleton": {"allowRelaying": {"else": {{ stalwart_relay_expr | trim | to_json }} }}}}, "c0"]]
"methodCalls": [["x:MtaStageRcpt/set", {"update": {"singleton": {"allowRelaying": {"else": {{ STALWART_RELAY_RULE | to_json }} }}}}, "c0"]]
}
@@ -12,8 +12,14 @@ MAILU_SMTP_CONTAINER={{ lookup('config', 'web-app-mailu', 'services.smtp.name')
MAIL_DOMAIN={{ STALWART_DOMAIN | quote }}
MAIL_HOST={{ lookup('email').host | quote }}
MIGRATION_SCRIPT={{ lookup('path_absolute', 'roles', application_id, 'files/python/migrate_from_mailu.py') | quote }}
NO_REPLY_EMAIL={{ lookup('users', 'no-reply').email | quote }}
NO_REPLY_SMTP_PASSWORD={{ lookup('email').password | quote }}
PYTHON_BIN={{ (lookup('env', 'PYTHON') or '/opt/venvs/infinito/bin/python') | quote }}
STALWART_MIGRATION_E2E={{ ((application_id in PRIMARY_APPS) and ('web-app-mailu' in group_names) and DEPLOYMENT_MODE == 'compose' and IS_STACK_HOST | bool) | string | lower }}
+2 -1
View File
@@ -65,7 +65,8 @@ STALWART_USERS: "{{ lookup('stalwart_users') }}"
STALWART_DMARC_RUF: "{{ lookup('config', application_id, 'users.administrator.email') }}"
STALWART_TRUSTED_NETWORKS: "{{ NETWORK_TRUSTED_PROXY_CIDRS }}"
STALWART_BOT_APP_PASSWORD_DESCRIPTION: "Infinito.Nexus submission"
STALWART_RELAY_RULE: "!is_empty(authenticated_as)"
STALWART_SSO_ENABLED: "{{ lookup('sso', application_id, 'is_enabled') | bool }}"
+4 -75
View File
@@ -253,7 +253,7 @@ class TestEmailLookup(unittest.TestCase):
_write_role_config(
self._tmp,
"web-app-mailprov",
{"sso": {"oidc": {"submission_via_relay": True}}},
{"sso": {"enabled": True}},
)
variables = {
"MAIL_PROVIDER": "web-app-mailprov",
@@ -269,56 +269,11 @@ class TestEmailLookup(unittest.TestCase):
self.assertFalse(result["start_tls"])
self.assertFalse(result["tls"])
def test_sso_relay_provider_disables_auth_and_uses_port_25(self) -> None:
# submission_via_relay + Keycloak deployed -> relay on 25, no auth, STARTTLS.
def test_a_provider_behind_sso_keeps_authenticated_submission(self) -> None:
_write_role_config(
self._tmp,
"web-app-mailprov",
{"sso": {"oidc": {"submission_via_relay": True}}},
)
variables = {
"MAIL_PROVIDER": "web-app-mailprov",
"group_names": ["web-app-mailprov", "web-app-keycloak"],
"groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]},
"TLS_ENABLED": True,
"inventory_hostname": "host1",
}
result = self.lookup.run(
[], variables=variables, roles_dir=str(self._tmp / "roles")
)[0]
self.assertEqual(result["port"], 25)
self.assertFalse(result["auth"])
self.assertTrue(result["start_tls"])
def test_sso_relay_inactive_without_keycloak(self) -> None:
# Same provider, but Keycloak is not deployed: keep authenticated 465.
_write_role_config(
self._tmp,
"web-app-mailprov",
{"sso": {"oidc": {"submission_via_relay": True}}},
)
variables = {
"MAIL_PROVIDER": "web-app-mailprov",
"group_names": ["web-app-mailprov"],
"groups": {"web-app-mailprov": ["host1"]},
"TLS_ENABLED": True,
"inventory_hostname": "host1",
}
result = self.lookup.run(
[], variables=variables, roles_dir=str(self._tmp / "roles")
)[0]
self.assertEqual(result["port"], 465)
self.assertTrue(result["auth"])
self.assertFalse(result["start_tls"])
def test_sso_relay_inactive_when_sso_enabled_pinned_false(self) -> None:
# A variant pins the provider's sso.enabled to a literal false while
# submission_via_relay stays true (role default) and Keycloak is still
# deployed: the provider keeps password auth, so no relay mode.
_write_role_config(
self._tmp,
"web-app-mailprov",
{"sso": {"enabled": False, "oidc": {"submission_via_relay": True}}},
{"sso": {"enabled": True}},
)
variables = {
"MAIL_PROVIDER": "web-app-mailprov",
@@ -332,6 +287,7 @@ class TestEmailLookup(unittest.TestCase):
)[0]
self.assertEqual(result["port"], 465)
self.assertTrue(result["auth"])
self.assertEqual(result["auth_mechanism"], "on")
self.assertFalse(result["start_tls"])
def test_a_provider_without_plaintext_submission_falls_back_to_its_mx(
@@ -396,33 +352,6 @@ class TestEmailLookup(unittest.TestCase):
self.assertTrue(result["auth"])
self.assertEqual(result["auth_mechanism"], "plain")
def test_sso_relay_active_with_untemplated_enabled_gate(self) -> None:
# The role default gates sso.enabled on group_names (raw Jinja here);
# the guard must not treat the untemplated string as false.
_write_role_config(
self._tmp,
"web-app-mailprov",
{
"sso": {
"enabled": "{{ 'web-app-keycloak' in group_names }}",
"oidc": {"submission_via_relay": True},
}
},
)
variables = {
"MAIL_PROVIDER": "web-app-mailprov",
"group_names": ["web-app-mailprov", "web-app-keycloak"],
"groups": {"web-app-mailprov": ["host1"], "web-app-keycloak": ["host1"]},
"TLS_ENABLED": True,
"inventory_hostname": "host1",
}
result = self.lookup.run(
[], variables=variables, roles_dir=str(self._tmp / "roles")
)[0]
self.assertEqual(result["port"], 25)
self.assertFalse(result["auth"])
self.assertTrue(result["start_tls"])
def test_computed_defaults_are_templated(self) -> None:
self.lookup._templar = _DummyTemplar(
{"DOMAIN_PRIMARY_RESOLVED": "mail.example.org"}
@@ -2,7 +2,7 @@
Ruby's ``net/smtp`` authenticates whenever a ``user_name`` or ``password`` is
present, falling back to its default auth type — ``authentication: nil`` does
NOT switch it off. So on the provider's SSO relay, where port 25 offers no
NOT switch it off. So on the provider's MX listener, where port 25 offers no
AUTH at all, leaving the credentials in the hash makes every send die with
``503 5.5.1 AUTH not allowed`` while GitLab still reports the mail as queued.
The credentials therefore have to be omitted, not merely unaccompanied by an