diff --git a/plugins/lookup/email.py b/plugins/lookup/email.py
index b42b88d661..df75b2afbd 100644
--- a/plugins/lookup/email.py
+++ b/plugins/lookup/email.py
@@ -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.
diff --git a/roles/web-app-stalwart/README.md b/roles/web-app-stalwart/README.md
index 83d455f38a..0ddffafa24 100644
--- a/roles/web-app-stalwart/README.md
+++ b/roles/web-app-stalwart/README.md
@@ -57,7 +57,7 @@ flowchart LR
STALWART -- "OIDC directory
(token validation)" --> KC
MAIL_VHOST -. "SPA login: PKCE
(stalwart-webui client)" .-> KC
WEBMAIL_VHOST -. "OAuth login" .-> KC
- APPS -- "STARTTLS :25, no AUTH
(SSO relay, trusted networks)" --> STALWART
+ APPS -- "SMTPS :465, AUTH
(bot credential from the token store)" --> STALWART
WORLD <-- "public mail ports
(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
diff --git a/roles/web-app-stalwart/files/test/probe_mail.py b/roles/web-app-stalwart/files/test/probe_mail.py
index b6210e7f4d..9b859d87bf 100755
--- a/roles/web-app-stalwart/files/test/probe_mail.py
+++ b/roles/web-app-stalwart/files/test/probe_mail.py
@@ -10,7 +10,10 @@ speaks IMAP through imaplib.
probe_mail.py send
probe_mail.py find
+ probe_mail.py relay
+`relay` exits 0 when an unauthenticated session is refused an external recipient
+and the same recipient is accepted once `` 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
diff --git a/roles/web-app-stalwart/files/test/relay.sh b/roles/web-app-stalwart/files/test/relay.sh
new file mode 100644
index 0000000000..1c5e980814
--- /dev/null
+++ b/roles/web-app-stalwart/files/test/relay.sh
@@ -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"
diff --git a/roles/web-app-stalwart/files/test/test.sh b/roles/web-app-stalwart/files/test/test.sh
index 3b5d6f53de..adf4d4ee51 100755
--- a/roles/web-app-stalwart/files/test/test.sh
+++ b/roles/web-app-stalwart/files/test/test.sh
@@ -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"
diff --git a/roles/web-app-stalwart/meta/services.yml b/roles/web-app-stalwart/meta/services.yml
index 9f0b082711..fd83fddb34 100644
--- a/roles/web-app-stalwart/meta/services.yml
+++ b/roles/web-app-stalwart/meta/services.yml
@@ -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
diff --git a/roles/web-app-stalwart/tasks/04_manage_user.yml b/roles/web-app-stalwart/tasks/04_manage_user.yml
index 2cd75d2f1a..b9af38c469 100644
--- a/roles/web-app-stalwart/tasks/04_manage_user.yml
+++ b/roles/web-app-stalwart/tasks/04_manage_user.yml
@@ -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
diff --git a/roles/web-app-stalwart/tasks/04_manage_user/app_password.yml b/roles/web-app-stalwart/tasks/04_manage_user/app_password.yml
new file mode 100644
index 0000000000..08b8cf593f
--- /dev/null
+++ b/roles/web-app-stalwart/tasks/04_manage_user/app_password.yml
@@ -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
diff --git a/roles/web-app-stalwart/tasks/06_oidc.yml b/roles/web-app-stalwart/tasks/06_oidc.yml
index fda058a667..a042e1f990 100644
--- a/roles/web-app-stalwart/tasks/06_oidc.yml
+++ b/roles/web-app-stalwart/tasks/06_oidc.yml
@@ -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
diff --git a/roles/web-app-stalwart/tasks/jmap_call.yml b/roles/web-app-stalwart/tasks/jmap_call.yml
index d41404ff6d..9232fc6038 100644
--- a/roles/web-app-stalwart/tasks/jmap_call.yml
+++ b/roles/web-app-stalwart/tasks/jmap_call.yml
@@ -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
diff --git a/roles/web-app-stalwart/templates/jmap/rcpt/create.json.j2 b/roles/web-app-stalwart/templates/jmap/rcpt/create.json.j2
deleted file mode 100644
index a5df60533b..0000000000
--- a/roles/web-app-stalwart/templates/jmap/rcpt/create.json.j2
+++ /dev/null
@@ -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"]]
-}
diff --git a/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2 b/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2
index 027616add2..b850276ba9 100644
--- a/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2
+++ b/roles/web-app-stalwart/templates/jmap/rcpt/update.json.j2
@@ -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"]]
}
diff --git a/roles/web-app-stalwart/templates/test.env.j2 b/roles/web-app-stalwart/templates/test.env.j2
index e74416f36a..2076165c5f 100644
--- a/roles/web-app-stalwart/templates/test.env.j2
+++ b/roles/web-app-stalwart/templates/test.env.j2
@@ -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 }}
diff --git a/roles/web-app-stalwart/vars/main.yml b/roles/web-app-stalwart/vars/main.yml
index 4d4cb42c1b..670c423955 100644
--- a/roles/web-app-stalwart/vars/main.yml
+++ b/roles/web-app-stalwart/vars/main.yml
@@ -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 }}"
diff --git a/tests/unit/python/plugins/lookup/test_email.py b/tests/unit/python/plugins/lookup/test_email.py
index 95b29c3efb..019b1ac16f 100644
--- a/tests/unit/python/plugins/lookup/test_email.py
+++ b/tests/unit/python/plugins/lookup/test_email.py
@@ -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"}
diff --git a/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py b/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py
index ac76ffb055..cc1884e0ac 100644
--- a/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py
+++ b/tests/unit/python/roles/web-app-gitlab/test_smtp_settings.py
@@ -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