Browse Source

Merge branch 'master' into minor

Michael Bromley 6 months ago
parent
commit
911f8e79cd

+ 148 - 0
.github/workflows/build_and_test.yml

@@ -0,0 +1,148 @@
+name: Build & Test
+on:
+  push:
+    branches:
+      - master
+      - minor
+      - major
+    paths:
+      - 'packages/**'
+      - 'package.json'
+      - 'package-lock.json'
+  pull_request:
+    branches:
+        - master
+        - major
+        - minor
+    paths:
+        - 'packages/**'
+        - 'package.json'
+        - 'package-lock.json'
+
+env:
+  CI: true
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  codegen:
+    uses: ./.github/workflows/codegen.yml
+  build:
+    name: build
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node: [ 20.x, 22.x ]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Use Node.js ${{ matrix.node }}
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node }}
+      - name: npm install
+        run: |
+          npm install
+          npm install --os=linux --cpu=x64 sharp
+      - name: Build
+        run: npm run build
+  unit-tests:
+    name: unit tests
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        node: [ 20.x, 22.x ]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Use Node.js ${{ matrix.node }}
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node }}
+      - name: npm install
+        run: |
+          npm install
+          npm install --os=linux --cpu=x64 sharp
+      - name: Build
+        run: npx lerna run ci
+      - name: Unit tests
+        run: npm run test
+  e2e-tests:
+    name: e2e tests
+    runs-on: ubuntu-latest
+    services:
+      mariadb:
+        # With v11.6.2+, a default was changed, (https://mariadb.com/kb/en/innodb-system-variables/#innodb_snapshot_isolation)
+        # which causes e2e test failures currently
+        image: bitnami/mariadb:11.5
+        env:
+          MARIADB_ROOT_USER: vendure
+          MARIADB_ROOT_PASSWORD: password
+        ports:
+          - 3306
+        options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
+      mysql:
+        image: bitnami/mysql:8.0
+        env:
+          MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password
+          MYSQL_ROOT_USER: vendure
+          MYSQL_ROOT_PASSWORD: password
+        ports:
+          - 3306
+        options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=20s --health-retries=10
+      postgres:
+        image: postgres:16
+        env:
+          POSTGRES_USER: vendure
+          POSTGRES_PASSWORD: password
+        ports:
+          - 5432
+        options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=3
+      elastic:
+        image: docker.elastic.co/elasticsearch/elasticsearch:7.1.1
+        env:
+          discovery.type: single-node
+          bootstrap.memory_lock: true
+          ES_JAVA_OPTS: -Xms512m -Xmx512m
+          # Elasticsearch will force read-only mode when total available disk space is less than 5%. Since we will
+          # be running on a shared Azure instance with 84GB SSD, we easily go below 5% available even when there are still
+          # > 3GB free. So we set this value to an absolute one rather than a percentage to prevent all the Elasticsearch
+          # e2e tests from failing.
+          cluster.routing.allocation.disk.watermark.low: 500mb
+          cluster.routing.allocation.disk.watermark.high: 200mb
+          cluster.routing.allocation.disk.watermark.flood_stage: 100mb
+        ports:
+          - 9200
+        options: --health-cmd="curl --silent --fail localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=3
+      redis:
+        image: bitnami/redis:7.4.1
+        env:
+          ALLOW_EMPTY_PASSWORD: yes
+        ports:
+          - 6379
+    strategy:
+      fail-fast: false
+      matrix:
+        node: [ 20.x, 22.x ]
+        db: [ sqljs, mariadb, mysql, postgres ]
+    steps:
+      - uses: actions/checkout@v4
+      - name: Use Node.js ${{ matrix.node }}
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node }}
+      - name: npm install
+        run: |
+          npm install
+          npm install --os=linux --cpu=x64 sharp
+      - name: Build
+        run: npx lerna run ci
+      - name: e2e tests
+        env:
+          E2E_MYSQL_PORT: ${{ job.services.mysql.ports['3306'] }}
+          E2E_MARIADB_PORT: ${{ job.services.mariadb.ports['3306'] }}
+          E2E_POSTGRES_PORT: ${{ job.services.postgres.ports['5432'] }}
+          E2E_ELASTIC_PORT: ${{ job.services.elastic.ports['9200'] }}
+          E2E_REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
+          DB: ${{ matrix.db }}
+        run: npm run e2e

+ 0 - 137
.github/workflows/build_and_test_branches.yml

@@ -1,137 +0,0 @@
-name: Build & Test Branches
-on:
-    push:
-        branches:
-            - major
-            - minor
-            - parallel-e2e
-        paths:
-            - 'packages/**'
-            - 'package.json'
-            - 'package-lock.json'
-
-env:
-    CI: true
-
-concurrency:
-    group: ${{ github.workflow }}-${{ github.ref }}
-    cancel-in-progress: true
-
-jobs:
-    codegen:
-        uses: ./.github/workflows/codegen.yml
-    build:
-        name: build
-        runs-on: ubuntu-latest
-        strategy:
-            matrix:
-                node: [20.x, 22.x]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npm run build
-    unit-tests:
-        name: unit tests
-        runs-on: ubuntu-latest
-        strategy:
-            matrix:
-                node: [20.x, 22.x]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npx lerna run ci
-            - name: Unit tests
-              run: npm run test
-    e2e-tests:
-        name: e2e tests
-        runs-on: ubuntu-latest
-        services:
-            mariadb:
-                image: bitnami/mariadb:11.5
-                env:
-                    MARIADB_ROOT_USER: vendure
-                    MARIADB_ROOT_PASSWORD: password
-                ports:
-                    - 3306
-                options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
-            mysql:
-                image: bitnami/mysql:8.0
-                env:
-                    MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password
-                    MYSQL_ROOT_USER: vendure
-                    MYSQL_ROOT_PASSWORD: password
-                ports:
-                    - 3306
-                options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=20s --health-retries=10
-            postgres:
-                image: postgres:16
-                env:
-                    POSTGRES_USER: vendure
-                    POSTGRES_PASSWORD: password
-                ports:
-                    - 5432
-                options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=3
-            elastic:
-                image: docker.elastic.co/elasticsearch/elasticsearch:7.1.1
-                env:
-                    discovery.type: single-node
-                    bootstrap.memory_lock: true
-                    ES_JAVA_OPTS: -Xms512m -Xmx512m
-                    # Elasticsearch will force read-only mode when total available disk space is less than 5%. Since we will
-                    # be running on a shared Azure instance with 84GB SSD, we easily go below 5% available even when there are still
-                    # > 3GB free. So we set this value to an absolute one rather than a percentage to prevent all the Elasticsearch
-                    # e2e tests from failing.
-                    cluster.routing.allocation.disk.watermark.low: 500mb
-                    cluster.routing.allocation.disk.watermark.high: 200mb
-                    cluster.routing.allocation.disk.watermark.flood_stage: 100mb
-                ports:
-                    - 9200
-                options: --health-cmd="curl --silent --fail localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=3
-            redis:
-                image: bitnami/redis:7.4.1
-                env:
-                    ALLOW_EMPTY_PASSWORD: yes
-                ports:
-                    - 6379
-        strategy:
-            fail-fast: false
-            matrix:
-                node: [20.x, 22.x]
-                db: [sqljs, mariadb, mysql, postgres]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npx lerna run ci
-            - name: e2e tests
-              env:
-                  E2E_MYSQL_PORT: ${{ job.services.mysql.ports['3306'] }}
-                  E2E_MARIADB_PORT: ${{ job.services.mariadb.ports['3306'] }}
-                  E2E_POSTGRES_PORT: ${{ job.services.postgres.ports['5432'] }}
-                  E2E_ELASTIC_PORT: ${{ job.services.elastic.ports['9200'] }}
-                  E2E_REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
-                  DB: ${{ matrix.db }}
-              run: npm run e2e

+ 0 - 137
.github/workflows/build_and_test_master.yml

@@ -1,137 +0,0 @@
-name: Build & Test
-on:
-    push:
-        branches:
-            - master
-        paths:
-            - 'packages/**'
-            - 'package.json'
-            - 'package-lock.json'
-
-env:
-    CI: true
-
-concurrency:
-    group: ${{ github.workflow }}-${{ github.ref }}
-    cancel-in-progress: true
-
-jobs:
-    codegen:
-        uses: ./.github/workflows/codegen.yml
-    build:
-        name: build
-        runs-on: ubuntu-latest
-        strategy:
-            matrix:
-                node: [20.x, 22.x]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npm run build
-    unit-tests:
-        name: unit tests
-        runs-on: ubuntu-latest
-        strategy:
-            matrix:
-                node: [20.x, 22.x]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npx lerna run ci
-            - name: Unit tests
-              run: npm run test
-    e2e-tests:
-        name: e2e tests
-        runs-on: ubuntu-latest
-        services:
-            mariadb:
-                # With v11.6.2+, a default was changed, (https://mariadb.com/kb/en/innodb-system-variables/#innodb_snapshot_isolation)
-                # which causes e2e test failures currently
-                image: bitnami/mariadb:11.5
-                env:
-                    MARIADB_ROOT_USER: vendure
-                    MARIADB_ROOT_PASSWORD: password
-                ports:
-                    - 3306
-                options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
-            mysql:
-                image: bitnami/mysql:8.0
-                env:
-                    MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password
-                    MYSQL_ROOT_USER: vendure
-                    MYSQL_ROOT_PASSWORD: password
-                ports:
-                    - 3306
-                options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=20s --health-retries=10
-            postgres:
-                image: postgres:16
-                env:
-                    POSTGRES_USER: vendure
-                    POSTGRES_PASSWORD: password
-                ports:
-                    - 5432
-                options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=3
-            elastic:
-                image: docker.elastic.co/elasticsearch/elasticsearch:7.1.1
-                env:
-                    discovery.type: single-node
-                    bootstrap.memory_lock: true
-                    ES_JAVA_OPTS: -Xms512m -Xmx512m
-                    # Elasticsearch will force read-only mode when total available disk space is less than 5%. Since we will
-                    # be running on a shared Azure instance with 84GB SSD, we easily go below 5% available even when there are still
-                    # > 3GB free. So we set this value to an absolute one rather than a percentage to prevent all the Elasticsearch
-                    # e2e tests from failing.
-                    cluster.routing.allocation.disk.watermark.low: 500mb
-                    cluster.routing.allocation.disk.watermark.high: 200mb
-                    cluster.routing.allocation.disk.watermark.flood_stage: 100mb
-                ports:
-                    - 9200
-                options: --health-cmd="curl --silent --fail localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=3
-            redis:
-                image: bitnami/redis:7.4.1
-                env:
-                    ALLOW_EMPTY_PASSWORD: yes
-                ports:
-                    - 6379
-        strategy:
-            fail-fast: false
-            matrix:
-                node: [20.x, 22.x]
-                db: [sqljs, mariadb, mysql, postgres]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npx lerna run ci
-            - name: e2e tests
-              env:
-                  E2E_MYSQL_PORT: ${{ job.services.mysql.ports['3306'] }}
-                  E2E_MARIADB_PORT: ${{ job.services.mariadb.ports['3306'] }}
-                  E2E_POSTGRES_PORT: ${{ job.services.postgres.ports['5432'] }}
-                  E2E_ELASTIC_PORT: ${{ job.services.elastic.ports['9200'] }}
-                  E2E_REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
-                  DB: ${{ matrix.db }}
-              run: npm run e2e

+ 0 - 137
.github/workflows/build_and_test_pr.yml

@@ -1,137 +0,0 @@
-name: Build & Test PR
-on:
-    pull_request:
-        branches:
-            - master
-            - major
-            - minor
-        paths:
-            - 'packages/**'
-            - 'package.json'
-            - 'package-lock.json'
-
-env:
-    CI: true
-
-concurrency:
-    group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
-    cancel-in-progress: true
-
-jobs:
-    codegen:
-        uses: ./.github/workflows/codegen.yml
-    build:
-        name: build
-        runs-on: ubuntu-latest
-        strategy:
-            matrix:
-                node: [20.x, 22.x]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npm run build
-    unit-tests:
-        name: unit tests
-        runs-on: ubuntu-latest
-        strategy:
-            matrix:
-                node: [20.x, 22.x]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npx lerna run ci
-            - name: Unit tests
-              run: npm run test
-    e2e-tests:
-        name: e2e tests
-        runs-on: ubuntu-latest
-        services:
-            mariadb:
-                image: bitnami/mariadb:11.5
-                env:
-                    MARIADB_ROOT_USER: vendure
-                    MARIADB_ROOT_PASSWORD: password
-                ports:
-                    - 3306
-                options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
-            mysql:
-                image: bitnami/mysql:8.0
-                env:
-                    MYSQL_AUTHENTICATION_PLUGIN: mysql_native_password
-                    MYSQL_ROOT_USER: vendure
-                    MYSQL_ROOT_PASSWORD: password
-                ports:
-                    - 3306
-                options: --health-cmd="mysqladmin ping --silent" --health-interval=10s --health-timeout=20s --health-retries=10
-            postgres:
-                image: postgres:16
-                env:
-                    POSTGRES_USER: vendure
-                    POSTGRES_PASSWORD: password
-                ports:
-                    - 5432
-                options: --health-cmd=pg_isready --health-interval=10s --health-timeout=5s --health-retries=3
-            elastic:
-                image: docker.elastic.co/elasticsearch/elasticsearch:7.1.1
-                env:
-                    discovery.type: single-node
-                    bootstrap.memory_lock: true
-                    ES_JAVA_OPTS: -Xms512m -Xmx512m
-                    # Elasticsearch will force read-only mode when total available disk space is less than 5%. Since we will
-                    # be running on a shared Azure instance with 84GB SSD, we easily go below 5% available even when there are still
-                    # > 3GB free. So we set this value to an absolute one rather than a percentage to prevent all the Elasticsearch
-                    # e2e tests from failing.
-                    cluster.routing.allocation.disk.watermark.low: 500mb
-                    cluster.routing.allocation.disk.watermark.high: 200mb
-                    cluster.routing.allocation.disk.watermark.flood_stage: 100mb
-                ports:
-                    - 9200
-                options: --health-cmd="curl --silent --fail localhost:9200/_cluster/health" --health-interval=10s --health-timeout=5s --health-retries=3
-            redis:
-                image: bitnami/redis:7.4.1
-                env:
-                    ALLOW_EMPTY_PASSWORD: yes
-                ports:
-                    - 6379
-        strategy:
-            fail-fast: false
-            matrix:
-                node: [20.x, 22.x]
-                db: [sqljs, mariadb, mysql, postgres]
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node }}
-            - name: npm install
-              run: |
-                  npm install
-                  npm install --os=linux --cpu=x64 sharp
-            - name: Build
-              run: npx lerna run ci
-            - name: e2e tests
-              env:
-                  E2E_MYSQL_PORT: ${{ job.services.mysql.ports['3306'] }}
-                  E2E_MARIADB_PORT: ${{ job.services.mariadb.ports['3306'] }}
-                  E2E_POSTGRES_PORT: ${{ job.services.postgres.ports['5432'] }}
-                  E2E_ELASTIC_PORT: ${{ job.services.elastic.ports['9200'] }}
-                  E2E_REDIS_PORT: ${{ job.services.redis.ports['6379'] }}
-                  DB: ${{ matrix.db }}
-              run: npm run e2e

+ 85 - 0
.github/workflows/publish_and_install.yml

@@ -0,0 +1,85 @@
+name: Publish & Install
+on:
+  push:
+    branches:
+      - master
+      - minor
+      - major
+    paths:
+      - 'packages/**'
+      - 'package.json'
+      - 'package-lock.json'
+  pull_request:
+    branches:
+      - master
+      - major
+      - minor
+    paths:
+      - 'packages/**'
+      - 'package.json'
+      - 'package-lock.json'
+
+defaults:
+  run:
+    shell: bash
+
+concurrency:
+  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  publish_install:
+    runs-on: ${{ matrix.os }}
+    strategy:
+      matrix:
+        os: [ ubuntu-latest, windows-latest, macos-latest ]
+        node-version: [ 20.x, 22.x ]
+      fail-fast: false
+    steps:
+      - uses: actions/checkout@v4
+      - name: Use Node.js ${{ matrix.node-version }}
+        uses: actions/setup-node@v4
+        with:
+          node-version: ${{ matrix.node-version }}
+      - name: Install Verdaccio
+        run: |
+          npm install -g verdaccio
+          npm install -g wait-on
+          tmp_registry_log=`mktemp`
+          mkdir -p $HOME/.config/verdaccio
+          cp -v ./.github/workflows/verdaccio/config.yaml $HOME/.config/verdaccio/config.yaml
+          nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
+          wait-on http://localhost:4873
+          TOKEN_RES=$(curl -XPUT \
+            -H "Content-type: application/json" \
+            -d '{ "name": "test", "password": "test" }' \
+            'http://localhost:4873/-/user/org.couchdb.user:test')
+          TOKEN=$(echo "$TOKEN_RES" | jq -r '.token')
+          npm set //localhost:4873/:_authToken $TOKEN
+      - name: Windows dependencies
+        if: matrix.os == 'windows-latest'
+        run: npm install -g @angular/cli
+      - name: npm install
+        run: |
+          npm install
+        env:
+          CI: true
+      - name: Publish to Verdaccio
+        run: |
+          nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
+          wait-on http://localhost:4873
+          npx lerna publish prepatch --preid ci --no-push --no-git-tag-version --no-commit-hooks --force-publish "*" --yes --dist-tag ci --registry http://localhost:4873
+      - name: Install via @vendure/create
+        run: |
+          mkdir -p $HOME/install
+          cd $HOME/install
+          nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
+          wait-on http://localhost:4873
+          npm set registry=http://localhost:4873
+          npm dist-tag ls @vendure/create
+          npx @vendure/create@ci test-app --ci --use-npm --log-level info
+      - name: Server smoke tests
+        run: |
+          cd $HOME/install/test-app
+          npm run dev &
+          node $GITHUB_WORKSPACE/.github/workflows/scripts/smoke-tests

+ 0 - 76
.github/workflows/publish_and_install_branches.yml

@@ -1,76 +0,0 @@
-name: Publish & Install Branches
-on:
-    push:
-        branches:
-            - major
-            - minor
-            - parallel-e2e
-        paths:
-            - 'packages/**'
-            - 'package.json'
-            - 'package-lock.json'
-            
-concurrency:
-    group: ${{ github.workflow }}-${{ github.ref }}
-    cancel-in-progress: true
-
-defaults:
-    run:
-        shell: bash
-
-jobs:
-    publish_install:
-        runs-on: ${{ matrix.os }}
-        strategy:
-            matrix:
-                os: [ubuntu-latest, windows-latest, macos-latest]
-                node-version: [20.x, 22.x]
-            fail-fast: false
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node-version }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node-version }}
-            - name: Install Verdaccio
-              run: |
-                  npm install -g verdaccio
-                  npm install -g wait-on
-                  tmp_registry_log=`mktemp`
-                  mkdir -p $HOME/.config/verdaccio
-                  cp -v ./.github/workflows/verdaccio/config.yaml $HOME/.config/verdaccio/config.yaml
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  TOKEN_RES=$(curl -XPUT \
-                    -H "Content-type: application/json" \
-                    -d '{ "name": "test", "password": "test" }' \
-                    'http://localhost:4873/-/user/org.couchdb.user:test')
-                  TOKEN=$(echo "$TOKEN_RES" | jq -r '.token')
-                  npm set //localhost:4873/:_authToken $TOKEN
-            - name: Windows dependencies
-              if: matrix.os == 'windows-latest'
-              run: npm install -g @angular/cli
-            - name: npm install
-              run: |
-                  npm install
-              env:
-                  CI: true
-            - name: Publish to Verdaccio
-              run: |
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  npx lerna publish prepatch --preid ci --no-push --no-git-tag-version --no-commit-hooks --force-publish "*" --yes --dist-tag ci --registry http://localhost:4873
-            - name: Install via @vendure/create
-              run: |
-                  mkdir -p $HOME/install
-                  cd $HOME/install
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  npm set registry=http://localhost:4873
-                  npm dist-tag ls @vendure/create
-                  npx @vendure/create@ci test-app --ci --use-npm --log-level info
-            - name: Server smoke tests
-              run: |
-                  cd $HOME/install/test-app
-                  npm run dev &
-                  node $GITHUB_WORKSPACE/.github/workflows/scripts/smoke-tests

+ 0 - 74
.github/workflows/publish_and_install_master.yml

@@ -1,74 +0,0 @@
-name: Publish & Install
-on:
-    push:
-        branches:
-            - master
-        paths:
-            - 'packages/**'
-            - 'package.json'
-            - 'package-lock.json'
-
-defaults:
-    run:
-        shell: bash
-
-concurrency:
-    group: ${{ github.workflow }}-${{ github.ref }}
-    cancel-in-progress: true
-
-jobs:
-    publish_install:
-        runs-on: ${{ matrix.os }}
-        strategy:
-            matrix:
-                os: [ubuntu-latest, windows-latest, macos-latest]
-                node-version: [20.x, 22.x]
-            fail-fast: false
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node-version }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node-version }}
-            - name: Install Verdaccio
-              run: |
-                  npm install -g verdaccio
-                  npm install -g wait-on
-                  tmp_registry_log=`mktemp`
-                  mkdir -p $HOME/.config/verdaccio
-                  cp -v ./.github/workflows/verdaccio/config.yaml $HOME/.config/verdaccio/config.yaml
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  TOKEN_RES=$(curl -XPUT \
-                    -H "Content-type: application/json" \
-                    -d '{ "name": "test", "password": "test" }' \
-                    'http://localhost:4873/-/user/org.couchdb.user:test')
-                  TOKEN=$(echo "$TOKEN_RES" | jq -r '.token')
-                  npm set //localhost:4873/:_authToken $TOKEN
-            - name: Windows dependencies
-              if: matrix.os == 'windows-latest'
-              run: npm install -g @angular/cli
-            - name: npm install
-              run: |
-                  npm install
-              env:
-                  CI: true
-            - name: Publish to Verdaccio
-              run: |
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  npx lerna publish prepatch --preid ci --no-push --no-git-tag-version --no-commit-hooks --force-publish "*" --yes --dist-tag ci --registry http://localhost:4873
-            - name: Install via @vendure/create
-              run: |
-                  mkdir -p $HOME/install
-                  cd $HOME/install
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  npm set registry=http://localhost:4873
-                  npm dist-tag ls @vendure/create
-                  npx @vendure/create@ci test-app --ci --use-npm --log-level info
-            - name: Server smoke tests
-              run: |
-                  cd $HOME/install/test-app
-                  npm run dev &
-                  node $GITHUB_WORKSPACE/.github/workflows/scripts/smoke-tests

+ 0 - 75
.github/workflows/publish_and_install_pr.yml

@@ -1,75 +0,0 @@
-name: Publish & Install PR
-on:
-    pull_request:
-        branches:
-            - master
-            - major
-            - minor
-        paths:
-            - 'packages/**'
-            - 'package.json'
-            - 'package-lock.json'
-
-concurrency:
-    group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
-    cancel-in-progress: true
-
-defaults:
-    run:
-        shell: bash
-jobs:
-    publish_install:
-        runs-on: ${{ matrix.os }}
-        strategy:
-            matrix:
-                os: [ubuntu-latest, windows-latest, macos-latest]
-                node-version: [20.x, 22.x]
-            fail-fast: false
-        steps:
-            - uses: actions/checkout@v4
-            - name: Use Node.js ${{ matrix.node-version }}
-              uses: actions/setup-node@v4
-              with:
-                  node-version: ${{ matrix.node-version }}
-            - name: Install Verdaccio
-              run: |
-                  npm install -g verdaccio
-                  npm install -g wait-on
-                  tmp_registry_log=`mktemp`
-                  mkdir -p $HOME/.config/verdaccio
-                  cp -v ./.github/workflows/verdaccio/config.yaml $HOME/.config/verdaccio/config.yaml
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  TOKEN_RES=$(curl -XPUT \
-                    -H "Content-type: application/json" \
-                    -d '{ "name": "test", "password": "test" }' \
-                    'http://localhost:4873/-/user/org.couchdb.user:test')
-                  TOKEN=$(echo "$TOKEN_RES" | jq -r '.token')
-                  npm set //localhost:4873/:_authToken $TOKEN
-            - name: Windows dependencies
-              if: matrix.os == 'windows-latest'
-              run: npm install -g @angular/cli
-            - name: npm install
-              run: |
-                  npm install
-              env:
-                  CI: true
-            - name: Publish to Verdaccio
-              run: |
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  npx lerna publish prepatch --preid ci --no-push --no-git-tag-version --no-commit-hooks --force-publish "*" --yes --dist-tag ci --registry http://localhost:4873
-            - name: Install via @vendure/create
-              run: |
-                  mkdir -p $HOME/install
-                  cd $HOME/install
-                  nohup verdaccio --config $HOME/.config/verdaccio/config.yaml &
-                  wait-on http://localhost:4873
-                  npm set registry=http://localhost:4873
-                  npm dist-tag ls @vendure/create
-                  npx @vendure/create@ci test-app --ci --use-npm --log-level info
-            - name: Server smoke tests
-              run: |
-                  cd $HOME/install/test-app
-                  npm run dev &
-                  node $GITHUB_WORKSPACE/.github/workflows/scripts/smoke-tests

+ 13 - 10
README.md

@@ -8,8 +8,8 @@ An open-source headless commerce platform built on [Node.js](https://nodejs.org)
 > We're phasing out our Angular-based Admin UI with support until June 2026:
 > [Read more here](https://vendure.io/blog/2025/02/vendure-react-admin-ui)
 
-[![Build Status](https://github.com/vendure-ecommerce/vendure/actions/workflows/build_and_test_master.yml/badge.svg)](https://github.com/vendure-ecommerce/vendure/actions/workflows/build_and_test_master.yml)
-[![Publish & Install](https://github.com/vendure-ecommerce/vendure/actions/workflows/publish_and_install_master.yml/badge.svg)](https://github.com/vendure-ecommerce/vendure/actions/workflows/publish_and_install_master.yml)
+[![Build Status](https://github.com/vendure-ecommerce/vendure/actions/workflows/build_and_test.yml/badge.svg?branch=master)](https://github.com/vendure-ecommerce/vendure/actions/workflows/build_and_test.yml)
+[![Publish & Install](https://github.com/vendure-ecommerce/vendure/actions/workflows/publish_and_install.yml/badge.svg?branch=master)](https://github.com/vendure-ecommerce/vendure/actions/workflows/publish_and_install.yml)
 [![Lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lerna.js.org/)
 
 ![vendure-github-social-banner](https://github.com/vendure-ecommerce/vendure/assets/24294584/ada25fa3-185d-45ce-896d-bece3685a829)
@@ -45,7 +45,7 @@ vendure/
 
 ## Contributing
 
-You are very much welcome to contribute to Vendure, we appreciate every pull request made, issue reported or any other form of feedback or input. 
+You are very much welcome to contribute to Vendure, we appreciate every pull request made, issue reported or any other form of feedback or input.
 
 Before getting started, please read our [Contribution Guidelines](https://github.com/vendure-ecommerce/vendure/blob/master/CONTRIBUTING.md) first to make the most out of your time and ours.
 
@@ -109,9 +109,11 @@ By default, if you do not specify the `DB` environment variable, it will use **M
 If you want to develop against **PostgreSQL**:
 
 1. Run the `postgres_16` Docker container.
+
 ```bash
 docker-compose up -d postgres_16
 ```
+
 2. Create a .env file in `/packages/dev-server` and declare the `DB` variable inside it:
 
     ```env
@@ -144,15 +146,13 @@ Default Admin UI credentials:
 Username: `superadmin`
 Password: `superadmin`
 
-
 ### Testing Admin UI changes locally
 
 If you are making changes to the Admin UI, you need to start the Admin UI independent from the dev-server:
 
-> [!NOTE] 
+> [!NOTE]
 > You don't need this step when you just use the Admin UI just
-to test backend changes since the `dev-server` package ships with a default admin-ui
-
+> to test backend changes since the `dev-server` package ships with a default admin-ui
 
 ```
 cd packages/admin-ui
@@ -164,11 +164,11 @@ This will run a separate process of admin-ui on "http://localhost:4200", you can
 Username: `superadmin`
 Password: `superadmin`
 
-This will auto restart when you make changes to the Admin UI. 
+This will auto restart when you make changes to the Admin UI.
 
 ### Testing your changes locally
 
-This example shows how to test changes to the `payments-plugin` package locally. 
+This example shows how to test changes to the `payments-plugin` package locally.
 This same workflow can be used for other packages as well.
 
 ### Terminal Setup
@@ -183,6 +183,7 @@ npm run watch
 ```
 
 **Terminal 2** - Run the development server:
+
 ```bash
 cd packages/dev-server
 npm run dev
@@ -191,15 +192,17 @@ npm run dev
 > [!NOTE]
 > After making changes, you need to stop and restart the development server to see your changes.
 
-> [!WARNING] 
+> [!WARNING]
 > If you are developing changes for the `core` package, you also need to watch the `common` package:
 
 in the root of the project:
+
 ```shell
 npm run watch:core-common
 ```
 
 #### Development Workflow Summary
+
 1. Start your package watcher (npm run watch)
 2. Start the dev-server (npm run dev)
 3. Make code changes

+ 101 - 96
docs/docs/reference/core-plugins/email-plugin/email-event-handler.md

@@ -1,14 +1,15 @@
 ---
-title: "EmailEventHandler"
+title: 'EmailEventHandler'
 isDefaultIndex: false
 generated: true
 ---
+
 <!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+
 import MemberInfo from '@site/src/components/MemberInfo';
 import GenerationInfo from '@site/src/components/GenerationInfo';
 import MemberDescription from '@site/src/components/MemberDescription';
 
-
 ## EmailEventHandler
 
 <GenerationInfo sourceFile="packages/email-plugin/src/handler/event-handler.ts" sourceLine="136" packageName="@vendure/email-plugin" />
@@ -18,16 +19,16 @@ The EmailEventHandler defines how the EmailPlugin will respond to a given event.
 A handler is created by creating a new <a href='/reference/core-plugins/email-plugin/email-event-listener#emaileventlistener'>EmailEventListener</a> and calling the `.on()` method
 to specify which event to respond to.
 
-*Example*
+_Example_
 
 ```ts
 const confirmationHandler = new EmailEventListener('order-confirmation')
-  .on(OrderStateTransitionEvent)
-  .filter(event => event.toState === 'PaymentSettled')
-  .setRecipient(event => event.order.customer.emailAddress)
-  .setFrom('{{ fromAddress }}')
-  .setSubject(`Order confirmation for #{{ order.code }}`)
-  .setTemplateVars(event => ({ order: event.order }));
+    .on(OrderStateTransitionEvent)
+    .filter(event => event.toState === 'PaymentSettled')
+    .setRecipient(event => event.order.customer.emailAddress)
+    .setFrom('{{ fromAddress }}')
+    .setSubject(`Order confirmation for #{{ order.code }}`)
+    .setTemplateVars(event => ({ order: event.order }));
 ```
 
 This example creates a handler which listens for the `OrderStateTransitionEvent` and if the Order has
@@ -39,22 +40,36 @@ also to locate the directory of the email template files. So in the example abov
 
 ## Handling other languages
 
-By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
-and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used
-to define the subject and body template for specific language and channel combinations.
+By default, a handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
+and body template.
+
+Since v2.0 the `.addTemplate()` method has been **deprecated**. To serve different templates—for example, based on the current
+`languageCode`—implement a custom <a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a> and pass it to `EmailPlugin.init({ templateLoader: new MyTemplateLoader() })`.
 
-The language is determined by looking at the `languageCode` property of the event's `ctx` (<a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>) object.
+The language is typically determined by the `languageCode` property of the event's `ctx` (<a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>) object, so the
+`loadTemplate()` method can use that to locate the correct template file.
 
-*Example*
+_Example_
 
 ```ts
-const extendedConfirmationHandler = confirmationHandler
-  .addTemplate({
-    channelCode: 'default',
-    languageCode: LanguageCode.de,
-    templateFile: 'body.de.hbs',
-    subject: 'Bestellbestätigung für #{{ order.code }}',
-  })
+import { EmailPlugin, TemplateLoader } from '@vendure/email-plugin';
+import { readFileSync } from 'fs';
+import path from 'path';
+
+class CustomLanguageAwareTemplateLoader implements TemplateLoader {
+    constructor(private templateDir: string) {}
+
+    async loadTemplate(_injector, ctx, { type, templateName }) {
+        // e.g. returns the content of "body.de.hbs" or "body.en.hbs" depending on ctx.languageCode
+        const filePath = path.join(this.templateDir, type, `${templateName}.${ctx.languageCode}.hbs`);
+        return readFileSync(filePath, 'utf-8');
+    }
+}
+
+EmailPlugin.init({
+    templateLoader: new CustomLanguageAwareTemplateLoader(path.join(__dirname, '../static/email/templates')),
+    handlers: defaultEmailHandlers,
+});
 ```
 
 ## Defining a custom handler
@@ -115,87 +130,75 @@ import { defaultEmailHandlers, EmailPlugin } from '@vendure/email-plugin';
 import { quoteRequestedHandler } from './plugins/quote-plugin';
 
 const config: VendureConfig = {
-  // Add an instance of the plugin to the plugins array
-  plugins: [
-    EmailPlugin.init({
-      handler: [...defaultEmailHandlers, quoteRequestedHandler],
-      // ... etc
-    }),
-  ],
+    // Add an instance of the plugin to the plugins array
+    plugins: [
+        EmailPlugin.init({
+            handler: [...defaultEmailHandlers, quoteRequestedHandler],
+            // ... etc
+        }),
+    ],
 };
 ```
 
-```ts title="Signature"
-class EmailEventHandler<T extends string = string, Event extends EventWithContext = EventWithContext> {
-    constructor(listener: EmailEventListener<T>, event: Type<Event>)
-    filter(filterFn: (event: Event) => boolean) => EmailEventHandler<T, Event>;
-    setRecipient(setRecipientFn: (event: Event) => string) => EmailEventHandler<T, Event>;
-    setLanguageCode(setLanguageCodeFn: (event: Event) => LanguageCode | undefined) => EmailEventHandler<T, Event>;
-    setTemplateVars(templateVarsFn: SetTemplateVarsFn<Event>) => EmailEventHandler<T, Event>;
-    setSubject(defaultSubject: string | SetSubjectFn<Event>) => EmailEventHandler<T, Event>;
-    setFrom(from: string) => EmailEventHandler<T, Event>;
-    setOptionalAddressFields(optionalAddressFieldsFn: SetOptionalAddressFieldsFn<Event>) => ;
-    setMetadata(optionalSetMetadataFn: SetMetadataFn<Event>) => ;
-    setAttachments(setAttachmentsFn: SetAttachmentsFn<Event>) => ;
-    addTemplate(config: EmailTemplateConfig) => EmailEventHandler<T, Event>;
-    loadData(loadDataFn: LoadDataFn<Event, R>) => EmailEventHandlerWithAsyncData<R, T, Event, EventWithAsyncData<Event, R>>;
-    setMockEvent(event: Omit<Event, 'ctx' | 'data'>) => EmailEventHandler<T, Event>;
-}
-```
-
 <div className="members-wrapper">
 
 ### constructor
 
-<MemberInfo kind="method" type={`(listener: <a href='/reference/core-plugins/email-plugin/email-event-listener#emaileventlistener'>EmailEventListener</a>&#60;T&#62;, event: Type&#60;Event&#62;) => EmailEventHandler`}   />
-
+<MemberInfo kind="method" type={`(listener: <a href='/reference/core-plugins/email-plugin/email-event-listener#emaileventlistener'>EmailEventListener</a>&#60;T&#62;, event: Type&#60;Event&#62;) => EmailEventHandler`} />
 
 ### filter
 
-<MemberInfo kind="method" type={`(filterFn: (event: Event) =&#62; boolean) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}   />
+<MemberInfo kind="method" type={`(filterFn: (event: Event) =&#62; boolean) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} />
 
 Defines a predicate function which is used to determine whether the event will trigger an email.
 Multiple filter functions may be defined.
+
 ### setRecipient
 
-<MemberInfo kind="method" type={`(setRecipientFn: (event: Event) =&#62; string) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}   />
+<MemberInfo kind="method" type={`(setRecipientFn: (event: Event) =&#62; string) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} />
 
 A function which defines how the recipient email address should be extracted from the incoming event.
 
 The recipient can be a plain email address: `'foobar@example.com'`
 Or with a formatted name (includes unicode support): `'Ноде Майлер <foobar@example.com>'`
 Or a comma-separated list of addresses: `'foobar@example.com, "Ноде Майлер" <bar@example.com>'`
+
 ### setLanguageCode
 
-<MemberInfo kind="method" type={`(setLanguageCodeFn: (event: Event) =&#62; <a href='/reference/typescript-api/common/language-code#languagecode'>LanguageCode</a> | undefined) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}  since="1.8.0"  />
+<MemberInfo kind="method" type={`(setLanguageCodeFn: (event: Event) =&#62; <a href='/reference/typescript-api/common/language-code#languagecode'>LanguageCode</a> | undefined) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} since="1.8.0" />
 
 A function which allows to override the language of the email. If not defined, the language from the context will be used.
+
 ### setTemplateVars
 
-<MemberInfo kind="method" type={`(templateVarsFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#settemplatevarsfn'>SetTemplateVarsFn</a>&#60;Event&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}   />
+<MemberInfo kind="method" type={`(templateVarsFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#settemplatevarsfn'>SetTemplateVarsFn</a>&#60;Event&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} />
 
 A function which returns an object hash of variables which will be made available to the Handlebars template
 and subject line for interpolation.
+
 ### setSubject
 
-<MemberInfo kind="method" type={`(defaultSubject: string | <a href='/reference/core-plugins/email-plugin/email-plugin-types#setsubjectfn'>SetSubjectFn</a>&#60;Event&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}   />
+<MemberInfo kind="method" type={`(defaultSubject: string | <a href='/reference/core-plugins/email-plugin/email-plugin-types#setsubjectfn'>SetSubjectFn</a>&#60;Event&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} />
 
 Sets the default subject of the email. The subject string may use Handlebars variables defined by the
 setTemplateVars() method.
+
 ### setFrom
 
-<MemberInfo kind="method" type={`(from: string) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}   />
+<MemberInfo kind="method" type={`(from: string) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} />
 
 Sets the default from field of the email. The from string may use Handlebars variables defined by the
 setTemplateVars() method.
+
 ### setOptionalAddressFields
 
-<MemberInfo kind="method" type={`(optionalAddressFieldsFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#setoptionaladdressfieldsfn'>SetOptionalAddressFieldsFn</a>&#60;Event&#62;) => `}  since="1.1.0"  />
+<MemberInfo kind="method" type={`(optionalAddressFieldsFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#setoptionaladdressfieldsfn'>SetOptionalAddressFieldsFn</a>&#60;Event&#62;) => `} since="1.1.0" />
 
 A function which allows <a href='/reference/core-plugins/email-plugin/email-plugin-types#optionaladdressfields'>OptionalAddressFields</a> to be specified such as "cc" and "bcc".
+
 ### setMetadata
 
-<MemberInfo kind="method" type={`(optionalSetMetadataFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#setmetadatafn'>SetMetadataFn</a>&#60;Event&#62;) => `}  since="3.1.0"  />
+<MemberInfo kind="method" type={`(optionalSetMetadataFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#setmetadatafn'>SetMetadataFn</a>&#60;Event&#62;) => `} since="3.1.0" />
 
 A function which allows <a href='/reference/core-plugins/email-plugin/email-plugin-types#emailmetadata'>EmailMetadata</a> to be specified for the email. This can be used
 to store arbitrary data about the email which can be used for tracking or other purposes.
@@ -204,23 +207,22 @@ It will be exposed in the <a href='/reference/core-plugins/email-plugin/email-se
 
 - An <a href='/reference/typescript-api/events/event-types#orderstatetransitionevent'>OrderStateTransitionEvent</a> occurs, and the EmailEventListener starts processing it.
 - The EmailEventHandler attaches metadata to the email:
-   ```ts
-   new EmailEventListener(EventType.ORDER_CONFIRMATION)
-     .on(OrderStateTransitionEvent)
-     .setMetadata(event => ({
-       type: EventType.ORDER_CONFIRMATION,
-       orderId: event.order.id,
-     }));
-  ```
+    ```ts
+    new EmailEventListener(EventType.ORDER_CONFIRMATION).on(OrderStateTransitionEvent).setMetadata(event => ({
+        type: EventType.ORDER_CONFIRMATION,
+        orderId: event.order.id,
+    }));
+    ```
 - Then, the EmailPlugin tries to send the email and publishes <a href='/reference/core-plugins/email-plugin/email-send-event#emailsendevent'>EmailSendEvent</a>,
-  passing ctx, emailDetails, error or success, and this metadata.
+  passing `ctx`, emailDetails, error or success, and this metadata.
 - In another part of the server, we have an eventBus that subscribes to EmailSendEvent. We can use
   `metadata.type` and `metadata.orderId` to identify the related order. For example, we can indicate on the
-   order that the email was successfully sent, or in case of an error, send a notification confirming
-   the order in another available way.
+  order that the email was successfully sent, or in case of an error, send a notification confirming
+  the order in another available way.
+
 ### setAttachments
 
-<MemberInfo kind="method" type={`(setAttachmentsFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#setattachmentsfn'>SetAttachmentsFn</a>&#60;Event&#62;) => `}   />
+<MemberInfo kind="method" type={`(setAttachmentsFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#setattachmentsfn'>SetAttachmentsFn</a>&#60;Event&#62;) => `} />
 
 Defines one or more files to be attached to the email. An attachment can be specified
 as either a `path` (to a file or URL) or as `content` which can be a string, Buffer or Stream.
@@ -230,31 +232,34 @@ into the job queue. For this reason the total size of all attachments passed as
 **less than ~50k**. If the attachments are greater than that limit, a warning will be logged and
 errors may result if using the DefaultJobQueuePlugin with certain DBs such as MySQL/MariaDB.
 
-*Example*
+_Example_
 
 ```ts
 const testAttachmentHandler = new EmailEventListener('activate-voucher')
-  .on(ActivateVoucherEvent)
-  // ... omitted some steps for brevity
-  .setAttachments(async (event) => {
-    const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
-    return [
-      {
-        filename: `voucher-${voucherCode}.jpg`,
-        path: imageUrl,
-      },
-    ];
-  });
+    .on(ActivateVoucherEvent)
+    // ... omitted some steps for brevity
+    .setAttachments(async event => {
+        const { imageUrl, voucherCode } = await getVoucherDataForUser(event.user.id);
+        return [
+            {
+                filename: `voucher-${voucherCode}.jpg`,
+                path: imageUrl,
+            },
+        ];
+    });
 ```
+
 ### addTemplate
 
-<MemberInfo kind="method" type={`(config: EmailTemplateConfig) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}   />
+<MemberInfo kind="method" deprecated="Define a custom TemplateLoader on plugin initalization to define templates based on the RequestContext.
+E.g. `EmailPlugin.init({ templateLoader: new CustomTemplateLoader() })`" type={`(config: EmailTemplateConfig) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} />
 
 Add configuration for another template other than the default `"body.hbs"`. Use this method to define specific
 templates for channels or languageCodes other than the default.
+
 ### loadData
 
-<MemberInfo kind="method" type={`(loadDataFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loaddatafn'>LoadDataFn</a>&#60;Event, R&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler-with-async-data#emaileventhandlerwithasyncdata'>EmailEventHandlerWithAsyncData</a>&#60;R, T, Event, <a href='/reference/core-plugins/email-plugin/email-plugin-types#eventwithasyncdata'>EventWithAsyncData</a>&#60;Event, R&#62;&#62;`}   />
+<MemberInfo kind="method" type={`(loadDataFn: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loaddatafn'>LoadDataFn</a>&#60;Event, R&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler-with-async-data#emaileventhandlerwithasyncdata'>EmailEventHandlerWithAsyncData</a>&#60;R, T, Event, <a href='/reference/core-plugins/email-plugin/email-plugin-types#eventwithasyncdata'>EventWithAsyncData</a>&#60;Event, R&#62;&#62;`} />
 
 Allows data to be loaded asynchronously which can then be used as template variables.
 The `loadDataFn` has access to the event, the TypeORM `Connection` object, and an
@@ -262,28 +267,28 @@ The `loadDataFn` has access to the event, the TypeORM `Connection` object, and a
 by the <a href='/reference/typescript-api/plugin/plugin-common-module#plugincommonmodule'>PluginCommonModule</a>. The return value of the `loadDataFn` will be
 added to the `event` as the `data` property.
 
-*Example*
+_Example_
 
 ```ts
 new EmailEventListener('order-confirmation')
-  .on(OrderStateTransitionEvent)
-  .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
-  .loadData(({ event, injector }) => {
-    const orderService = injector.get(OrderService);
-    return orderService.getOrderPayments(event.order.id);
-  })
-  .setTemplateVars(event => ({
-    order: event.order,
-    payments: event.data,
-  }))
-  // ...
+    .on(OrderStateTransitionEvent)
+    .filter(event => event.toState === 'PaymentSettled' && !!event.order.customer)
+    .loadData(({ event, injector }) => {
+        const orderService = injector.get(OrderService);
+        return orderService.getOrderPayments(event.order.id);
+    })
+    .setTemplateVars(event => ({
+        order: event.order,
+        payments: event.data,
+    }));
+// ...
 ```
+
 ### setMockEvent
 
-<MemberInfo kind="method" type={`(event: Omit&#60;Event, 'ctx' | 'data'&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`}   />
+<MemberInfo kind="method" type={`(event: Omit&#60;Event, 'ctx' | 'data'&#62;) => <a href='/reference/core-plugins/email-plugin/email-event-handler#emaileventhandler'>EmailEventHandler</a>&#60;T, Event&#62;`} />
 
 Optionally define a mock Event which is used by the dev mode mailbox app for generating mock emails
 from this handler, which is useful when developing the email templates.
 
-
 </div>

+ 12 - 18
docs/docs/reference/core-plugins/email-plugin/template-loader.md

@@ -1,22 +1,23 @@
 ---
-title: "TemplateLoader"
+title: 'TemplateLoader'
 isDefaultIndex: false
 generated: true
 ---
+
 <!-- This file was generated from the Vendure source. Do not modify. Instead, re-run the "docs:build" script -->
+
 import MemberInfo from '@site/src/components/MemberInfo';
 import GenerationInfo from '@site/src/components/GenerationInfo';
 import MemberDescription from '@site/src/components/MemberDescription';
 
-
 ## TemplateLoader
 
 <GenerationInfo sourceFile="packages/email-plugin/src/template-loader/template-loader.ts" sourceLine="32" packageName="@vendure/email-plugin" />
 
 Loads email templates based on the given request context, type and template name
-and return the template as a string.
+and returns the template as a string.
 
-*Example*
+_Example_
 
 ```ts
 import { EmailPlugin, TemplateLoader } from '@vendure/email-plugin';
@@ -46,20 +47,19 @@ interface TemplateLoader {
 
 ### loadTemplate
 
-<MemberInfo kind="method" type={`(injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loadtemplateinput'>LoadTemplateInput</a>) => Promise&#60;string&#62;`}   />
+<MemberInfo kind="method" type={`(injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, input: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loadtemplateinput'>LoadTemplateInput</a>) => Promise&#60;string&#62;`} />
 
 Load template and return it's content as a string
+
 ### loadPartials
 
-<MemberInfo kind="method" type={`() => Promise&#60;Partial[]&#62;`}   />
+<MemberInfo kind="method" type={`() => Promise&#60;Partial[]&#62;`} />
 
 Load partials and return their contents.
 This method is only called during initialization, i.e. during server startup.
 
-
 </div>
 
-
 ## FileBasedTemplateLoader
 
 <GenerationInfo sourceFile="packages/email-plugin/src/template-loader/file-based-template-loader.ts" sourceLine="17" packageName="@vendure/email-plugin" />
@@ -74,27 +74,21 @@ class FileBasedTemplateLoader implements TemplateLoader {
     loadPartials() => Promise<Partial[]>;
 }
 ```
-* Implements: <code><a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a></code>
-
 
+- Implements: <code><a href='/reference/core-plugins/email-plugin/template-loader#templateloader'>TemplateLoader</a></code>
 
 <div className="members-wrapper">
 
 ### constructor
 
-<MemberInfo kind="method" type={`(templatePath: string) => FileBasedTemplateLoader`}   />
-
+<MemberInfo kind="method" type={`(templatePath: string) => FileBasedTemplateLoader`} />
 
 ### loadTemplate
 
-<MemberInfo kind="method" type={`(_injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, _ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, { type, templateName }: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loadtemplateinput'>LoadTemplateInput</a>) => Promise&#60;string&#62;`}   />
-
+<MemberInfo kind="method" type={`(_injector: <a href='/reference/typescript-api/common/injector#injector'>Injector</a>, _ctx: <a href='/reference/typescript-api/request/request-context#requestcontext'>RequestContext</a>, { type, templateName }: <a href='/reference/core-plugins/email-plugin/email-plugin-types#loadtemplateinput'>LoadTemplateInput</a>) => Promise&#60;string&#62;`} />
 
 ### loadPartials
 
-<MemberInfo kind="method" type={`() => Promise&#60;Partial[]&#62;`}   />
-
-
-
+<MemberInfo kind="method" type={`() => Promise&#60;Partial[]&#62;`} />
 
 </div>

+ 25 - 11
packages/email-plugin/src/handler/event-handler.ts

@@ -47,20 +47,34 @@ import {
  * ## Handling other languages
  *
  * By default, the handler will respond to all events on all channels and use the same subject ("Order confirmation for #12345" above)
- * and body template. Where the server is intended to support multiple languages, the `.addTemplate()` method may be used
- * to define the subject and body template for specific language and channel combinations.
+ * and body template.
  *
- * The language is determined by looking at the `languageCode` property of the event's `ctx` ({@link RequestContext}) object.
+ * Since v2.0 the `.addTemplate()` method has been **deprecated**. To serve different templates — for example, based on the current
+ * `languageCode` — implement a custom {@link TemplateLoader} and pass it to `EmailPlugin.init({ templateLoader: new MyTemplateLoader() })`.
+ *
+ * The language is typically determined by the `languageCode` property of the event's `ctx` ({@link RequestContext}) object, so the
+ * `loadTemplate()` method can use that to locate the correct template file.
  *
  * @example
  * ```ts
- * const extendedConfirmationHandler = confirmationHandler
- *   .addTemplate({
- *     channelCode: 'default',
- *     languageCode: LanguageCode.de,
- *     templateFile: 'body.de.hbs',
- *     subject: 'Bestellbestätigung für #{{ order.code }}',
- *   })
+ * import { EmailPlugin, TemplateLoader } from '\@vendure/email-plugin';
+ * import { readFileSync } from 'fs';
+ * import path from 'path';
+ *
+ * class CustomLanguageAwareTemplateLoader implements TemplateLoader {
+ *   constructor(private templateDir: string) {}
+ *
+ *   async loadTemplate(_injector, ctx, { type, templateName }) {
+ *     // e.g. returns the content of "body.de.hbs" or "body.en.hbs" depending on ctx.languageCode
+ *     const filePath = path.join(this.templateDir, type, `${templateName}.${ctx.languageCode}.hbs`);
+ *     return readFileSync(filePath, 'utf-8');
+ *   }
+ * }
+ *
+ * EmailPlugin.init({
+ *   templateLoader: new CustomLanguageAwareTemplateLoader(path.join(__dirname, '../static/email/templates')),
+ *   handlers: defaultEmailHandlers,
+ * });
  * ```
  *
  * ## Defining a custom handler
@@ -102,7 +116,7 @@ import {
  *             of the quote you recently requested:
  *         </mj-text>
  *
- *         <--! your custom email layout goes here -->
+ *         <!-- your custom email layout goes here -->
  *     </mj-column>
  * </mj-section>
  *

+ 49 - 1
packages/email-plugin/src/plugin.spec.ts

@@ -28,7 +28,14 @@ import { EmailEventHandler } from './handler/event-handler';
 import { EmailPlugin } from './plugin';
 import { EmailSender } from './sender/email-sender';
 import { FileBasedTemplateLoader } from './template-loader/file-based-template-loader';
-import { EmailDetails, EmailPluginOptions, EmailTransportOptions } from './types';
+import { TemplateLoader } from './template-loader/template-loader';
+import {
+    EmailDetails,
+    Partial as EmailPartial,
+    EmailPluginOptions,
+    EmailTransportOptions,
+    LoadTemplateInput,
+} from './types';
 
 describe('EmailPlugin', () => {
     let eventBus: EventBus;
@@ -1003,6 +1010,47 @@ describe('EmailPlugin', () => {
             expect(onSend.mock.calls[0][0].from).toBe('"test from" <noreply@test.com>');
         });
     });
+    // Only in case of custom template loader - part of the jsDoc - not used in the core code
+    describe('CustomLanguageAwareTemplateLoader example', () => {
+        it('loads language-specific template correctly', async () => {
+            class CustomLanguageAwareTemplateLoader implements TemplateLoader {
+                constructor(private templateDir: string) {}
+
+                async loadTemplate(
+                    _injector: Injector,
+                    context: RequestContext,
+                    { type, templateName }: LoadTemplateInput,
+                ) {
+                    const filePath = path.join(
+                        this.templateDir,
+                        type,
+                        `${templateName}.${context.languageCode}.hbs`,
+                    );
+                    return readFileSync(filePath, 'utf-8');
+                }
+
+                async loadPartials(): Promise<EmailPartial[]> {
+                    return [];
+                }
+            }
+
+            const templatePath = path.join(__dirname, '../test-templates');
+            const loader = new CustomLanguageAwareTemplateLoader(templatePath);
+
+            const requestContext = RequestContext.deserialize({
+                _channel: { code: DEFAULT_CHANNEL_CODE },
+                _languageCode: LanguageCode.de,
+            } as any);
+
+            const result = await loader.loadTemplate({} as Injector, requestContext, {
+                type: 'test',
+                templateName: 'body',
+                templateVars: {},
+            });
+
+            expect(result).toContain('German body');
+        });
+    });
 });
 
 class FakeCustomSender implements EmailSender {