Files
computer-playbook/roles/web-app-stalwart/tasks/04_manage_user.yml
kevinveenbirkenbachandClaude Opus 5.5 08ef66d8de 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>
2026-10-08 08:57:22 +02:00

60 lines
2.6 KiB
YAML

---
# Bots authenticate over SMTP with a derived sha256-hex secret instead of the
# raw inventory password: consumers embed email.password unescaped in dotenv
# URLs and YAML, where the password charset ('[', '@', '#', ...) breaks parsing.
- name: "🔍 Resolve effective secret for '{{ stalwart_user_name }}'"
ansible.builtin.set_fact:
stalwart_user_secret: >-
{{ stalwart_user_password | hash('sha256')
if 'bot' in stalwart_user_roles else stalwart_user_password }}
- name: "📁 Create Stalwart account '{{ stalwart_user_name }}@{{ STALWART_DOMAIN }}'"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_tolerate_rejected: true
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'create': {'a': {
'@type': 'User',
'name': stalwart_user_name,
'domainId': stalwart_domain_id,
'description': (lookup('users', stalwart_user_key).email | default(stalwart_user_name)),
'credentials': {'0': {'@type': 'Password', 'secret': stalwart_user_secret}}
}}}, 'c0']]} | to_json }}
- name: "🔍 Resolve pre-existing account id for '{{ stalwart_user_name }}'"
ansible.builtin.set_fact:
stalwart_existing_account_id: >-
{{ ((stalwart_jmap.methodResponses[0][1].notCreated | default({}))
.get('a', {}).get('objectId', {}) or {}).get('id', '') }}
- name: "🔄 Update password for existing account '{{ stalwart_user_name }}'"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_body: >-
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'update': {stalwart_existing_account_id: {
'credentials': {'0': {'@type': 'Password', 'secret': stalwart_user_secret}}
}}}, 'c0']]} | to_json }}
when: stalwart_existing_account_id | length > 0
- name: "💾 Persist Stalwart submission credential for bot '{{ 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_user_secret }}"
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