---
# 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 the Stalwart accounts and their effective secrets"
ansible.builtin.set_fact:
stalwart_accounts: >-
{%- set accounts = [] -%}
{%- for key, user in STALWART_USERS.items() -%}
{%- set bot = 'bot' in (user.roles | default([])) -%}
{%- set _ = accounts.append({
'key': key,
'name': user.username,
'address': user.username ~ '@' ~ STALWART_DOMAIN,
'description': user.email | default(user.username),
'bot': bot,
'secret': user.password | hash('sha256') if bot else user.password,
'stored': (user.tokens | default({}, true)).get(application_id, '')}) -%}
{%- endfor -%}
{{ accounts }}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "π Create the Stalwart accounts"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_tolerate_rejected: true
stalwart_jmap_body: >-
{%- set create = {} -%}
{%- for account in stalwart_accounts -%}
{%- set user = {
'@type': 'User',
'name': account.name,
'domainId': stalwart_domain_id,
'description': account.description} -%}
{%- if not (STALWART_SSO_ENABLED | bool) -%}
{%- set _ = user.update({'credentials': {'0': {'@type': 'Password', 'secret': account.secret}}}) -%}
{%- endif -%}
{%- set _ = create.update({'u' ~ loop.index0: user}) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'create': create}, 'c0'],
['x:Account/get', {'ids': none}, 'c1']]} | to_json }}
- name: "π Match every account to its Stalwart id"
ansible.builtin.set_fact:
stalwart_accounts: >-
{%- set result = stalwart_jmap.methodResponses[0][1] -%}
{%- set listed = {} -%}
{%- for held in stalwart_jmap.methodResponses[1][1].list if held.domainId == stalwart_domain_id -%}
{%- set _ = listed.update({held.name | lower: held}) -%}
{%- endfor -%}
{%- set accounts = [] -%}
{%- for account in stalwart_accounts -%}
{%- set created = (result.created | default({}, true)).get('u' ~ loop.index0) or {} -%}
{%- set refusal = (result.notCreated | default({}, true)).get('u' ~ loop.index0) or {} -%}
{%- set held = listed.get(account.name | lower) or {} -%}
{%- set conflict = refusal.get('objectId') or {} -%}
{%- set credentials = held.get('credentials') or {} -%}
{%- set passwords = credentials | dict2items | selectattr('value.@type', 'equalto', 'Password')
| map(attribute='key') | list -%}
{%- set _ = accounts.append(account | combine({
'id': created.get('id') or held.get('id', ''),
'existing': refusal.get('type') == 'primaryKeyViolation' and conflict.get('object') == 'Account'
and held.get('id') is not none and conflict.get('id') == held.get('id'),
'password_key': passwords | first if passwords
else (credentials | map('int') | max + 1 if credentials else 0),
'refusal': refusal})) -%}
{%- endfor -%}
{{ accounts }}
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "β
Ensure Stalwart created or already holds every account"
ansible.builtin.assert:
that: stalwart_account_refused | length == 0
fail_msg: "Stalwart refused the accounts {{ stalwart_account_refused }}"
quiet: true
vars:
stalwart_account_refused: >-
{%- set refused = {} -%}
{%- for account in stalwart_accounts -%}
{%- if not account.id or (account.refusal and not account.existing) -%}
{%- set _ = refused.update({account.address: account.refusal}) -%}
{%- endif -%}
{%- endfor -%}
{{ refused }}
- name: "π Update the password of every pre-existing account"
when:
- not (STALWART_SSO_ENABLED | bool)
- stalwart_accounts | selectattr('existing') | list | length > 0
block:
- name: "π Send the passwords of the pre-existing accounts"
include_tasks: jmap_call.yml
vars:
stalwart_jmap_tolerate_rejected: true
stalwart_jmap_body: >-
{%- set changes = {} -%}
{%- for account in stalwart_accounts if account.existing -%}
{%- set _ = changes.update({account.id: {
'credentials/' ~ account.password_key ~ '/secret': account.secret}}) -%}
{%- endfor -%}
{{ {'using': ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
'methodCalls': [['x:Account/set', {'update': changes}, 'c0']]} | to_json }}
- name: "β
Ensure Stalwart took the password of every pre-existing account"
ansible.builtin.assert:
that: stalwart_account_refused | length == 0
fail_msg: "Stalwart refused the password of the accounts {{ stalwart_account_refused }}"
quiet: true
vars:
stalwart_account_refused: >-
{%- set rejected = stalwart_jmap.methodResponses[0][1].notUpdated | default({}, true) -%}
{%- set refused = {} -%}
{%- for account in stalwart_accounts if account.id in rejected -%}
{%- set _ = refused.update({account.address: rejected[account.id]}) -%}
{%- endfor -%}
{{ refused }}
- name: "πΎ Persist the Stalwart submission credential of every bot"
ansible.builtin.include_role:
name: sys-token-store
tasks_from: write_many.yml
vars:
sys_token_store_entries: >-
{%- set entries = [] -%}
{%- for bot in stalwart_accounts if bot.bot -%}
{%- set _ = entries.append({'user_key': bot.key, 'app': application_id, 'token': bot.secret}) -%}
{%- endfor -%}
{{ entries }}
when:
- not (STALWART_SSO_ENABLED | bool)
- stalwart_accounts | selectattr('bot') | list | length > 0
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"
- name: "π Keep an app password for every bot under SSO"
include_tasks: 04_manage_user/app_password.yml
loop: "{{ stalwart_accounts | selectattr('bot') | batch(STALWART_JMAP_MAX_METHOD_CALLS) | list }}"
loop_control:
loop_var: stalwart_bots
label: "{{ stalwart_bots | map(attribute='address') | join(', ') }}"
when: STALWART_SSO_ENABLED | bool
no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}"