04_manage_user.ymlΒΆ

---
# 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 }}"