diff --git a/.github/workflows/ci-v2.yml b/.github/workflows/ci-v2.yml
deleted file mode 100644
index bc14cdf9c2ba..000000000000
--- a/.github/workflows/ci-v2.yml
+++ /dev/null
@@ -1,232 +0,0 @@
-name: CI v2
-on:
- pull_request:
-
-permissions:
- actions: read
- contents: read
-
-concurrency:
- group: ci-v2-${{ github.event.pull_request.number }}
- cancel-in-progress: true
-
-env:
- # turbo --affected diffs against this ref. CI checkouts have origin/ but no local main.
- TURBO_SCM_BASE: origin/${{ github.base_ref }}
- TURBO_TELEMETRY_DISABLED: "1"
-
-jobs:
- changes:
- runs-on: ubuntu-latest
- if: github.repository == 'activepieces/activepieces'
- outputs:
- packages: ${{ steps.affected.outputs.packages }}
- all: ${{ steps.affected.outputs.all }}
- steps:
- - uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - name: Find affected packages
- id: affected
- run: |
- set -euo pipefail
- ALL=false
- # Files outside any package (workflows, root config, tools) are invisible to turbo's
- # graph, so a change there runs everything. Docs and markdown never do.
- OUTSIDE_PACKAGES=$(git diff --name-only "$TURBO_SCM_BASE...HEAD" | grep -vE '^(packages/|docs/|brain/|\.agents/|\.claude/)|\.md$' || true)
- if [ -n "$OUTSIDE_PACKAGES" ]; then
- ALL=true
- fi
- # Pieces follow the old ci.yml rule: a piece runs when its own files changed, and every
- # piece runs when pieces/framework or pieces/common changed. A change to a core package
- # (core-piece-types, core-utils, ...) does not pull the 700+ pieces in, even though they
- # depend on it; a break there is caught when pieces are published, not in the PR.
- if ! DIRECT=$(npx --yes turbo@2.9.14 ls --filter="[$TURBO_SCM_BASE...HEAD]" --output=json | jq -c '[.packages.items[].name]') \
- || ! AFFECTED=$(npx --yes turbo@2.9.14 ls --affected --output=json | jq -c '[.packages.items[].name]'); then
- ALL=true
- PACKAGES='[]'
- else
- PACKAGES=$(jq -cn --argjson direct "$DIRECT" --argjson affected "$AFFECTED" '
- ($direct | any(. == "@activepieces/pieces-framework" or . == "@activepieces/pieces-common")) as $allPieces
- | [$affected[] | select((startswith("@activepieces/piece-") | not) or $allPieces or IN($direct[]))]')
- fi
- echo "all=$ALL" >> "$GITHUB_OUTPUT"
- echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
- echo "all=$ALL"
- echo "packages=$PACKAGES"
-
- lint:
- runs-on: ubuntu-latest
- needs: changes
- if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]'
- steps:
- - uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/setup
- with:
- turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
- turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
- turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
- turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
-
- - name: Lint core packages
- if: needs.changes.outputs.all == 'true'
- run: npm run lint-core
-
- # Also on full runs: a PR that edits a workflow and pieces/framework in one go still needs
- # every piece linted, and the changes job's list is what knows that.
- - name: Lint affected packages
- env:
- AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }}
- run: |
- set -euo pipefail
- FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | "--filter=" + .' | tr '\n' ' ')
- if [ -n "$FILTERS" ]; then
- npx turbo run lint $FILTERS
- fi
-
- build-test:
- runs-on: ubuntu-latest
- needs: changes
- if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]'
- services:
- redis:
- image: redis:7-alpine
- options: >-
- --health-cmd "redis-cli ping"
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
- ports:
- - 6379:6379
- env:
- AP_REDIS_HOST: localhost
- AP_REDIS_PORT: "6379"
- steps:
- - uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - uses: ./.github/actions/setup
- with:
- turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
- turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
- turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
- turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
-
- # One turbo invocation so every package builds once: the affected packages from the changes
- # job, plus the core set on full runs. --affected and --filter intersect in turbo, so the
- # union is built here as explicit filters. api is excluded because its `test` script runs
- # the three integration suites serially; the api-tests matrix covers those.
- - name: Typecheck, build and test
- env:
- AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }}
- RUN_ALL: ${{ needs.changes.outputs.all }}
- run: |
- set -euo pipefail
- FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | select(. != "api") | "--filter=" + .' | tr '\n' ' ')
- if [ "$RUN_ALL" = "true" ]; then
- FILTERS="$FILTERS $(npx tsx tools/scripts/test-filters.ts)"
- fi
- npx turbo run typecheck build test $FILTERS
-
- - name: Check new migrations are rollback-safe
- run: npx tsx tools/scripts/check-migration-rollback.ts
-
- # The api e2e tests import the worker's source, and the worker resolves @activepieces/sandbox
- # through its built dist, so the api jobs build worker too and also run when worker is affected.
- api-tests:
- name: api (${{ matrix.name }})
- runs-on: ubuntu-latest
- needs: changes
- if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') || contains(needs.changes.outputs.packages, '"worker"')
- strategy:
- fail-fast: false
- matrix:
- include:
- - name: ce
- tasks: test-ce
- - name: cloud
- tasks: test-cloud
- - name: ee, unit, migrations
- tasks: test-ee test-unit check-migrations
- services:
- redis:
- image: redis:7-alpine
- options: >-
- --health-cmd "redis-cli ping"
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
- ports:
- - 6379:6379
- env:
- # .env.tests pins AP_REDIS_HOST=redis, the alias inside the job's docker network, which a
- # non-container job is not on. dotenv does not override values already in the environment.
- AP_REDIS_HOST: localhost
- AP_REDIS_PORT: "6379"
- steps:
- - uses: actions/checkout@v5
-
- - uses: ./.github/actions/setup
- with:
- turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
- turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
- turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
- turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
-
- - run: npx turbo run build ${{ matrix.tasks }} --filter=api --filter=worker
-
- # Only the tool-search integration tests, against real PostgreSQL + pgvector. The api-tests
- # matrix runs them on PGLITE, which papers over node-postgres driver behaviour.
- tool-search-postgres:
- name: tool-search (postgres)
- runs-on: ubuntu-latest
- needs: changes
- if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"')
- services:
- postgres:
- image: pgvector/pgvector:pg16
- env:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- POSTGRES_DB: activepieces
- options: >-
- --health-cmd pg_isready
- --health-interval 10s
- --health-timeout 5s
- --health-retries 5
- ports:
- - 5432:5432
- env:
- AP_DB_TYPE: POSTGRES
- AP_POSTGRES_HOST: localhost
- AP_POSTGRES_PORT: "5432"
- AP_POSTGRES_DATABASE: activepieces
- AP_POSTGRES_USERNAME: postgres
- AP_POSTGRES_PASSWORD: postgres
- AP_POSTGRES_USE_SSL: "false"
- steps:
- - uses: actions/checkout@v5
-
- - uses: ./.github/actions/setup
- with:
- turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
- turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
- turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
- turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
-
- - name: Enable pgvector extension
- run: |
- PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d activepieces \
- -c 'CREATE EXTENSION IF NOT EXISTS vector;'
-
- - run: npx turbo run build --filter=api
-
- # Serial: every file migrates the one shared service DB on DataSource.initialize().
- - name: Run tool-search integration tests on Postgres
- working-directory: packages/server/api
- run: npx vitest run test/integration/ce/tool-search --bail 1 --passWithNoTests=false --no-file-parallelism
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a7943960b93..84d743d86ff8 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,64 +6,92 @@ permissions:
actions: read
contents: read
+concurrency:
+ group: ci-${{ github.event.pull_request.number }}
+ cancel-in-progress: true
+
+env:
+ # turbo --affected diffs against this ref. CI checkouts have origin/ but no local main.
+ TURBO_SCM_BASE: origin/${{ github.base_ref }}
+ TURBO_TELEMETRY_DISABLED: "1"
jobs:
- lint:
+ changes:
runs-on: ubuntu-latest
if: github.repository == 'activepieces/activepieces'
+ outputs:
+ packages: ${{ steps.affected.outputs.packages }}
+ all: ${{ steps.affected.outputs.all }}
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- - uses: actions/setup-node@v6
- with:
- node-version: 24
+ - name: Find affected packages
+ id: affected
+ run: |
+ set -euo pipefail
+ ALL=false
+ # Files outside any package (workflows, root config, tools) are invisible to turbo's
+ # graph, so a change there runs everything. Docs and markdown never do.
+ OUTSIDE_PACKAGES=$(git diff --name-only "$TURBO_SCM_BASE...HEAD" | grep -vE '^(packages/|docs/|brain/|\.agents/|\.claude/)|\.md$' || true)
+ if [ -n "$OUTSIDE_PACKAGES" ]; then
+ ALL=true
+ fi
+ # Pieces override the dependency graph: a piece runs when its own files changed, and every
+ # piece runs when pieces/framework or pieces/common changed. A change to a core package
+ # (core-piece-types, core-utils, ...) does not pull the 700+ pieces in, even though they
+ # depend on it; a break there is caught when pieces are published, not in the PR.
+ if ! DIRECT=$(npx --yes turbo@2.9.14 ls --filter="[$TURBO_SCM_BASE...HEAD]" --output=json | jq -c '[.packages.items[].name]') \
+ || ! AFFECTED=$(npx --yes turbo@2.9.14 ls --affected --output=json | jq -c '[.packages.items[].name]'); then
+ ALL=true
+ PACKAGES='[]'
+ else
+ PACKAGES=$(jq -cn --argjson direct "$DIRECT" --argjson affected "$AFFECTED" '
+ ($direct | any(. == "@activepieces/pieces-framework" or . == "@activepieces/pieces-common")) as $allPieces
+ | [$affected[] | select((startswith("@activepieces/piece-") | not) or $allPieces or IN($direct[]))]')
+ fi
+ echo "all=$ALL" >> "$GITHUB_OUTPUT"
+ echo "packages=$PACKAGES" >> "$GITHUB_OUTPUT"
+ echo "all=$ALL"
+ echo "packages=$PACKAGES"
- - uses: oven-sh/setup-bun@v2
+ lint:
+ runs-on: ubuntu-latest
+ needs: changes
+ if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]'
+ steps:
+ - uses: actions/checkout@v5
with:
- bun-version: latest
+ fetch-depth: 0
- - uses: actions/cache@v4
+ - uses: ./.github/actions/setup
with:
- path: ~/.bun/install/cache
- key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- restore-keys: ${{ runner.os }}-bun-
+ turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
+ turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
+ turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
+ turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
- - run: bun install
-
- - name: Turbo Cache
- uses: rharkor/caching-for-turbo@v2.4.2
- with:
- provider: s3
- s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
- s3-region: auto
- s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
- s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
- s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
- max-age: 3w
- max-size: 20gb
+ - name: Lint core packages
+ if: needs.changes.outputs.all == 'true'
+ run: npm run lint-core
- - name: Check if framework or common pieces are changed
- id: check-framework-common
+ # Also on full runs: a PR that edits a workflow and pieces/framework in one go still needs
+ # every piece linted, and the changes job's list is what knows that.
+ - name: Lint affected packages
+ env:
+ AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }}
run: |
- CHANGED_FILES="$(git diff --name-only HEAD origin/main | tr '\n' ' ')"
- if echo "$CHANGED_FILES" | grep -q "pieces/framework\|pieces/common"; then
- echo "framework_or_common_changed=true" >> $GITHUB_OUTPUT
- else
- echo "framework_or_common_changed=false" >> $GITHUB_OUTPUT
+ set -euo pipefail
+ FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | "--filter=" + .' | tr '\n' ' ')
+ if [ -n "$FILTERS" ]; then
+ npx turbo run lint $FILTERS
fi
- - name: Lint core projects
- run: npm run lint-core
-
- - name: Lint all pieces projects
- if: steps.check-framework-common.outputs.framework_or_common_changed == 'true'
- run: npm run lint-pieces
-
- main:
+ build-test:
runs-on: ubuntu-latest
- if: github.repository == 'activepieces/activepieces'
+ needs: changes
+ if: needs.changes.outputs.all == 'true' || needs.changes.outputs.packages != '[]'
services:
redis:
image: redis:7-alpine
@@ -75,11 +103,6 @@ jobs:
ports:
- 6379:6379
env:
- # packages/server/api/.env.tests pins AP_REDIS_HOST=redis, the alias the service container
- # answers to inside the job's docker network, which a non-container job is not on. dotenv
- # (vitest.setup.ts) does not override values already in the environment, so this wins and
- # the tests reach the published port. AP_REDIS_HOST/PORT are in turbo.json
- # globalPassThroughEnv; without that turbo's strict env mode would drop them.
AP_REDIS_HOST: localhost
AP_REDIS_PORT: "6379"
steps:
@@ -87,116 +110,83 @@ jobs:
with:
fetch-depth: 0
- - uses: actions/setup-node@v6
- with:
- node-version: 24
-
- - uses: oven-sh/setup-bun@v2
- with:
- bun-version: latest
-
- - uses: actions/cache@v4
+ - uses: ./.github/actions/setup
with:
- path: ~/.bun/install/cache
- key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- restore-keys: ${{ runner.os }}-bun-
-
- - run: bun install
-
- - name: Turbo Cache
- uses: rharkor/caching-for-turbo@v2.4.2
- with:
- provider: s3
- s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
- s3-region: auto
- s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
- s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
- s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
- max-age: 3w
- max-size: 20gb
-
- - name: Get changed files
- id: changed-files
- run: echo "files=$(git diff --name-only HEAD origin/main | tr '\n' ' ')" >> $GITHUB_OUTPUT
-
- - name: Check if framework or common pieces are changed
- id: check-framework-common
- run: |
- CHANGED_FILES="${{ steps.changed-files.outputs.files }}"
- if echo "$CHANGED_FILES" | grep -q "pieces/framework\|pieces/common"; then
- echo "framework_or_common_changed=true" >> $GITHUB_OUTPUT
- else
- echo "framework_or_common_changed=false" >> $GITHUB_OUTPUT
- fi
-
- - name: Extract pieces package names from changed files
- id: extract-pieces
- run: |
- PIECES=$(echo "${{ steps.changed-files.outputs.files }}" | grep -oE "packages/pieces/(community|core)/[^/]+" | awk -F'/' '{print $NF}' | sort -u | tr '\n' ',' | sed 's/,$//')
- if [ -n "$PIECES" ]; then
- FILTERS=$(echo "$PIECES" | tr ',' '\n' | sed 's/.*/{&}/' | tr '\n' ',' | sed 's/,$//')
- echo "pieces_filters=$FILTERS" >> $GITHUB_OUTPUT
- else
- echo "pieces_filters=" >> $GITHUB_OUTPUT
- fi
-
- - name: Typecheck web
- run: npx turbo run typecheck --filter=web
-
- - name: Build core projects
- run: npx turbo run build --filter=web --filter=worker --filter=api --filter=@activepieces/engine --filter=@activepieces/piece-http --filter=@activepieces/piece-data-mapper --filter=@activepieces/piece-approval --filter=@activepieces/piece-webhook --filter=@activepieces/piece-delay
-
- - name: Build changed pieces projects
- if: steps.extract-pieces.outputs.pieces_filters != '' && steps.check-framework-common.outputs.framework_or_common_changed == 'false'
- run: |
- IFS=',' read -ra PIECES <<< "${{ steps.extract-pieces.outputs.pieces_filters }}"
- FILTER_ARGS=""
- for piece in "${PIECES[@]}"; do
- piece_name=$(echo "$piece" | tr -d '{}')
- if [ -f "packages/pieces/community/${piece_name}/package.json" ] || [ -f "packages/pieces/core/${piece_name}/package.json" ]; then
- FILTER_ARGS="$FILTER_ARGS --filter=@activepieces/piece-${piece_name}"
- fi
- done
- if [ -n "$FILTER_ARGS" ]; then
- npx turbo run build $FILTER_ARGS
- fi
-
- - name: Build all pieces projects
- if: steps.check-framework-common.outputs.framework_or_common_changed == 'true'
- run: npx turbo run build --filter='@activepieces/piece-*'
-
- - name: Run all tests and migration checks in parallel
+ turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
+ turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
+ turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
+ turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
+
+ # One turbo invocation so every package builds once: the affected packages from the changes
+ # job, plus the core set on full runs. --affected and --filter intersect in turbo, so the
+ # union is built here as explicit filters. api is excluded because its `test` script runs
+ # the three integration suites serially; the api-tests matrix covers those.
+ - name: Typecheck, build and test
+ env:
+ AFFECTED_PACKAGES: ${{ needs.changes.outputs.packages }}
+ RUN_ALL: ${{ needs.changes.outputs.all }}
run: |
set -euo pipefail
- pids=()
+ FILTERS=$(echo "$AFFECTED_PACKAGES" | jq -r '.[] | select(. != "api") | "--filter=" + .' | tr '\n' ' ')
+ if [ "$RUN_ALL" = "true" ]; then
+ FILTERS="$FILTERS $(npx tsx tools/scripts/test-filters.ts)"
+ fi
+ npx turbo run typecheck build test $FILTERS
- npx turbo run test $(npx tsx tools/scripts/test-filters.ts) &
- pids+=($!)
+ - name: Check new migrations are rollback-safe
+ run: npx tsx tools/scripts/check-migration-rollback.ts
- npx turbo run test-unit test-ce test-ee test-cloud check-migrations --filter=api &
- pids+=($!)
+ # The api e2e tests import the worker's source, and the worker resolves @activepieces/sandbox
+ # through its built dist, so the api jobs build worker too and also run when worker is affected.
+ api-tests:
+ name: api (${{ matrix.name }})
+ runs-on: ubuntu-latest
+ needs: changes
+ if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"') || contains(needs.changes.outputs.packages, '"worker"')
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: ce
+ tasks: test-ce
+ - name: cloud
+ tasks: test-cloud
+ - name: ee, unit, migrations
+ tasks: test-ee test-unit check-migrations
+ services:
+ redis:
+ image: redis:7-alpine
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 6379:6379
+ env:
+ # .env.tests pins AP_REDIS_HOST=redis, the alias inside the job's docker network, which a
+ # non-container job is not on. dotenv does not override values already in the environment.
+ AP_REDIS_HOST: localhost
+ AP_REDIS_PORT: "6379"
+ steps:
+ - uses: actions/checkout@v5
- npx tsx tools/scripts/check-migration-rollback.ts &
- pids+=($!)
+ - uses: ./.github/actions/setup
+ with:
+ turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
+ turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
+ turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
+ turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
- status=0
- for pid in "${pids[@]}"; do
- if ! wait "$pid"; then
- status=1
- fi
- done
- exit $status
+ - run: npx turbo run build ${{ matrix.tasks }} --filter=api --filter=worker
- # Runs ONLY the tool-search integration tests against a real PostgreSQL + pgvector
- # service container. The default `main` job above runs these on PGLITE, which papers
- # over node-postgres-specific driver behaviour (e.g. TypeORM `.query()` returning
- # `[rows, count]` instead of a plain rows array). This job exists to catch that class
- # of bug. It is intentionally scoped and additive — it does NOT change how the main
- # suite runs.
+ # Only the tool-search integration tests, against real PostgreSQL + pgvector. The api-tests
+ # matrix runs them on PGLITE, which papers over node-postgres driver behaviour.
tool-search-postgres:
name: tool-search (postgres)
runs-on: ubuntu-latest
- if: github.repository == 'activepieces/activepieces'
+ needs: changes
+ if: needs.changes.outputs.all == 'true' || contains(needs.changes.outputs.packages, '"api"')
services:
postgres:
image: pgvector/pgvector:pg16
@@ -212,8 +202,6 @@ jobs:
ports:
- 5432:5432
env:
- # dotenv (vitest.setup.ts) loads .env.tests WITHOUT overriding values already
- # present in the environment, so these job-level vars win over the PGLITE defaults.
AP_DB_TYPE: POSTGRES
AP_POSTGRES_HOST: localhost
AP_POSTGRES_PORT: "5432"
@@ -223,56 +211,22 @@ jobs:
AP_POSTGRES_USE_SSL: "false"
steps:
- uses: actions/checkout@v5
- with:
- fetch-depth: 0
-
- - uses: actions/setup-node@v6
- with:
- node-version: 24
-
- - uses: oven-sh/setup-bun@v2
- with:
- bun-version: latest
-
- - uses: actions/cache@v4
- with:
- path: ~/.bun/install/cache
- key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
- restore-keys: ${{ runner.os }}-bun-
-
- - run: bun install
- - name: Turbo Cache
- uses: rharkor/caching-for-turbo@v2.4.2
+ - uses: ./.github/actions/setup
with:
- provider: s3
- s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
- s3-region: auto
- s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
- s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
- s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
- max-age: 3w
- max-size: 20gb
+ turbo-s3-bucket: ${{ secrets.TURBO_CACHE_S3_BUCKET }}
+ turbo-s3-endpoint: ${{ secrets.TURBO_CACHE_S3_ENDPOINT }}
+ turbo-s3-access-key-id: ${{ secrets.TURBO_CACHE_S3_ACCESS_KEY_ID }}
+ turbo-s3-secret-access-key: ${{ secrets.TURBO_CACHE_S3_SECRET_ACCESS_KEY }}
- name: Enable pgvector extension
- # The pgvector image ships the extension binary but does not auto-create it in the
- # database. The tool_search_index migration skips table creation when `vector` is
- # absent, and the DB-only test never boots the server path that would CREATE it,
- # so create it explicitly before migrations run.
run: |
PGPASSWORD=postgres psql -h localhost -p 5432 -U postgres -d activepieces \
-c 'CREATE EXTENSION IF NOT EXISTS vector;'
- - name: Build api
- run: npx turbo run build --filter=api
+ - run: npx turbo run build --filter=api
+ # Serial: every file migrates the one shared service DB on DataSource.initialize().
- name: Run tool-search integration tests on Postgres
working-directory: packages/server/api
- # --no-file-parallelism: these files all point at the one shared `activepieces`
- # service DB and each runs migrations on `DataSource.initialize()`. Vitest's default
- # forked parallelism runs those migration passes concurrently, which collide on
- # shared catalog objects (the migrations table, en_natural collation, added columns)
- # and fail non-deterministically. Serial execution migrates once; later files skip
- # via the migrations table. (PGLITE gives each file its own DB, so the default suite
- # is unaffected — only this shared-Postgres job needs serializing.)
run: npx vitest run test/integration/ce/tool-search --bail 1 --passWithNoTests=false --no-file-parallelism
diff --git a/.github/workflows/warm-ci-cache.yml b/.github/workflows/warm-ci-cache.yml
index 5ad0b7c67300..5068063aa56c 100644
--- a/.github/workflows/warm-ci-cache.yml
+++ b/.github/workflows/warm-ci-cache.yml
@@ -1,6 +1,6 @@
name: Warm CI cache
# GitHub only lets a pull request restore caches created on the same PR or on the base branch.
-# CI v2 runs on pull requests only, so this seeds the base-branch copies (bun downloads and the
+# CI runs on pull requests only, so this seeds the base-branch copies (bun downloads and the
# compiled redis-memory-server binary) that every new PR's first run restores.
on:
push:
diff --git a/brain/knowledge/ai-intelligence/ai-agents.md b/brain/knowledge/ai-intelligence/ai-agents.md
index 988c63e0c765..b791144568ca 100644
--- a/brain/knowledge/ai-intelligence/ai-agents.md
+++ b/brain/knowledge/ai-intelligence/ai-agents.md
@@ -10,6 +10,16 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs
- A step either carries its own configuration in the flow version's step settings — `settings.input` holds `agentTools`, `structuredOutput`, `prompt`, `maxSteps` and `aiProviderModel` (`{ provider, model, configId }`) — or stores the `externalId` of a saved **Agent** (`agent` table, `ee/agent/agent-entity.ts`) under `agentId` and carries nothing else. A linked step is resolved at run start, so improving the agent improves the next run of every flow using it. The two are exclusive: a step that sends both is refused.
- Configured entirely in the Flow Builder (`web/src/app/builder/step-settings/agent-settings/`); a test panel runs a single agent step. `AgentTimeline` renders `AgentStepBlock[]` from the output as markdown blocks + expandable tool-call cards.
+- **One engine, four doors.** Chat, agent chat, the agent builder and `run_agent` are not four systems. Each creates an `agent_conversation` row and runs the same `EXECUTE_AGENT_RUN` worker job, and the worker calls back over `getAgentConfig` (`ee/agent/rpc/agent-config-rpc.ts`) to ask which model, tools and instructions to use. The assistant chat is an agent turn with no agent row. Which tier list a door resolves against follows `namesItsOwnModel` in that RPC: FLOW_STEP and AGENT name their own model and read the flow tiers; CHAT and AGENT_BUILDER read the chat tiers. An agent therefore resolves the same model everywhere it is used: configured, chatted with, or dropped in a flow.
+
+| Door (`AgentRunSource`) | Model comes from | Stored as |
+|---|---|---|
+| `CHAT` | `conversation.modelName`, picked in the UI | tier id |
+| `AGENT` | `agent.draft.modelName`; no picker is shown while chatting (`ai-chat-box.tsx` hides it when `agentId` is set) | concrete id |
+| `AGENT_BUILDER` | `conversation.modelName` | tier id |
+| `FLOW_STEP` | `agent.published.modelName`, or the step's own `input.aiProviderModel.model` | concrete id |
+
+Three things follow. `run_agent` has two modes: pointed at a saved agent (`agentId` set) the step sends no model and the agent row decides; inline, it sends `input.aiProviderModel.model`. An agent row carries two configs: agent-page chat reads `agent.draft`, a `run_agent` step reads `agent.published` (via `resolvePublishedAgent`), so the same agent can already run different models through different doors. The fast model crosses every door: `firstStepUsesFastModel` (`run-agent-turn.ts`) runs the first step of almost every turn on the fast tier, flow steps included, whatever the user picked. `execute-agent-run.ts` persists `config.tier.id` onto `conversation.modelName` when the job carried no `modelName`, so conversations already hold tier ids today (inline `run_agent`, eval runs, every `AGENT_BUILDER`), read back by `chat-tool-billing.ts`, `chat-analytics-sync.ts` and `pinnedModelOf`.
### Tool types (AgentTool discriminated union)
@@ -20,6 +30,8 @@ A flow step type (the `run_agent` action of `@activepieces/piece-ai`) that runs
- **PredefinedInputsStructure** — per-field `AGENT_DECIDE` / `CHOOSE_YOURSELF` / `LEAVE_EMPTY` baked into the tool so the agent knows which inputs it controls.
### Gotchas
+- **A chat turn's data lives in jsonb, so recording something per turn needs no migration.** `agent_conversation.messages` is a `jsonb` column and each entry is a `PersistedAgentMessageSchema`, which already carries optional per-turn fields (`thinkingDurationMs`, `feedback`). Adding another optional one is a zod field plus a `@activepieces/shared` patch bump — no column, no migration, no rollback flag. The conversation row itself only has `modelName`, which is why "record it on the conversation" is usually the wrong instinct: a user can switch tier mid-conversation, so anything that describes one turn belongs on the message, not the row.
+- **But the server does not build those messages, so "no migration" does not mean "cheap".** `messages` arrives already assembled from the worker over RPC (`saveAgentMessages` in `rpc/conversation-rpc.ts`), and the server only persists it and bills off it. Putting a new per-turn field on a message therefore means changing the RPC request schema and the worker that fills it, not editing a server file. Costing this off the jsonb shape alone gave an estimate of about an hour for work that is really a change to the engine contract. Check who authors the object before you price a change to it.
- **`CHAT_HIDDEN_TOOL_NAMES` is load-bearing for the MCP Activity feed, not just for chat UX.** The chat reaches the MCP server as an ordinary HTTP client, and `mcp_activity` records `ap_run_action`. The only thing keeping chat activity out of that feed is `ap_run_action` sitting in `CHAT_HIDDEN_TOOL_NAMES` (`core/shared/src/lib/ee/agent/tool-phases.ts`), filtered worker-side in `agent-mcp-client.ts`. Remove it and the chat starts writing rows into a tab meant for external MCP clients, with no test failing. See the MCP Server page for the enforcement that was designed and deliberately not built.
- Gated by `platform.plan.agentsEnabled`; when off, the step type is hidden from the piece selector. Off by default on Community, on for Cloud plans that include it.
diff --git a/brain/knowledge/ai-intelligence/ai-providers.md b/brain/knowledge/ai-intelligence/ai-providers.md
index be0a26e5ebb4..6c9620816dea 100644
--- a/brain/knowledge/ai-intelligence/ai-providers.md
+++ b/brain/knowledge/ai-intelligence/ai-providers.md
@@ -12,9 +12,9 @@ Lets platform admins configure one or more LLM backends for AI pieces in flows.
- Supported providers (10): `openai`, `anthropic`, `google`, `azure`, `openrouter`, `bedrock`, `mistral`, `cloudflare-gateway`, `custom` (OpenAI-compatible, e.g. Ollama/LM Studio), `activepieces` (auto-provisioned via OpenRouter).
### How it works
-- `GET /` list (auto-creates ACTIVEPIECES when `aiCreditsEnabled`); `GET /:provider/config` returns decrypted auth (engine-only); `GET /:provider/models` (cached); `POST /` create (validates creds first); `POST /:id` update; `DELETE /:id`.
+- `GET /` list (auto-creates ACTIVEPIECES when `aiCreditsEnabled`); `GET /:provider/models` (cached); `POST /` create (validates creds first); `POST /:id` update; `DELETE /:id`.
- For ACTIVEPIECES, update is blocked by an early return (only enabling `enabledForChat` is allowed); deletion is allowed and self-healing — `listProviders` recreates the managed row.
-- Engine integration: AI pieces call `GET /v1/ai-providers/{provider}/config` on **every AI action execution** (no per-run caching), authorized by the engine token.
+- Engine integration: the AI piece no longer fetches credentials. It POSTs `/v1/ai/execute`, the server enqueues an `EXECUTE_AI` worker job, and the worker resolves the provider over the `resolveAiProvider` RPC. `GET /:provider/config` was removed in [#15494](https://github.com/activepieces/activepieces/pull/15494).
- For the managed ACTIVEPIECES provider that config route is also the **credit gate**: `assertCreditsAndAppSumoNotExceeded` (`platform/billing-provider.ts`) throws `QUOTA_EXCEEDED` when the Autumn credit or AppSumo balance is blocked — fires per AI call, but usage is only metered post-run, so in-flight spend is invisible to it. See decision 000016 (`brain/decisions/000016-managed-ai-metering-moves-to-centralized-worker-execution.md`).
- Activepieces provisioning: `getOrCreateActivePiecesProviderAuthConfig()` → `enrichWithKeysIfNeeded()` mints an OpenRouter key. No system job is scheduled — renewal/top-up is driven by Autumn (`autoTopUps` in `autumn-billing.ts`), not Stripe.
@@ -45,6 +45,23 @@ Prices are rounded to three decimals in the generator, both to kill float artefa
among them — carry continuously floating five-decimal prices. Three decimals is below anything the UI
renders and preserves every real price; the cheapest in the set is 0.01.
+### Model tiers
+
+The published tier file, `https://cdn.activepieces.com/ai/pricing.json`, carries two lists: **flow tiers**
+(`tiers`, `defaultTierId`, `fastTierId`) for the surfaces that name their own model (FLOW_STEP and AGENT),
+and **chat tiers** (`chatTiers`, `chatDefaultTierId`, `chatFastTierId`) for CHAT and AGENT_BUILDER. The
+chat keys are optional; when absent, chat reads the flow list. Each list must contain its own default and
+fast tier and have unique ids. There are no cross-list rules: the same id may sit on both lists pointing at
+different models, and a tier may exist on one list only. A file that fails any rule is rejected whole and
+the release's bundled tiers serve instead.
+
+`modelTierCatalog.current(surface)` (`packages/server/utils/src/model-tier-catalog.ts`) is the single
+accessor: synchronous, never throws, serves the cached file or `ACTIVEPIECES_CHAT_TIERS`, and refreshes in
+the background every 15 minutes with a 5-minute back-off after a failure. `warmUp()` runs at API boot from
+`aiProviderService.setup()`, bounded to 2 s so a dead CDN cannot block boot. Today only `warmUp()` calls it: the
+resolution helpers in `ee/agent/agent-model-resolution.ts` (`findTier`, `resolveTier`, `FAST_TIER_ID`) still read
+the bundled list and are the next thing to move. `AP_MODEL_TIERS_URL` overrides
+the URL. The file is published from the console in `ap-analytics`; see decision 000041.
### Gotchas
- **A catalog change takes up to ~2 days to reach a dropdown, through three caches in series.** CDN edge (`--cache-control max-age=3600`, 1h) → the server's in-memory catalog (`CATALOG_TTL_MS`, 24h) → `modelsCache` (flushed by the nightly `cron.schedule('0 0 * * *')`). So "I republished but the UI still shows the old price" is expected, not a bug; restarting the API short-circuits the two in-process layers. The edge TTL is deliberately 1h and not the 604800 that `publish-embed-sdk.yml` uses — that path is version-stamped, ours is a stable key rewritten in place, so a week-long edge cache would pin stale prices for a week.
- **`Sync AI Model Catalog` is the only workflow that proves the CDN S3 credentials still work — a green `Publish Embed SDK` proves nothing.** Both read the same `CDN_S3_ACCESS_KEY_ID` / `CDN_S3_SECRET_ACCESS_KEY` / `CDN_S3_BUCKET` / `CDN_S3_ENDPOINT` secrets, but `publish-embed-sdk.yml` guards its upload job on `needs.check.outputs.publish == 'true'`, which is false whenever the version in `packages/ee/embed-sdk/package.json` is already on the CDN. Its daily cron then finishes in about nine seconds with the upload skipped and the run still marked success, so the key can rot for weeks behind a green tick — that is how the model-catalog cron came to fail with `InvalidAccessKeyId` on three consecutive Mondays while the embed SDK looked healthy. To date a dead key, find the last embed-SDK run whose `publish` job actually ran (`gh run list --workflow=publish-embed-sdk.yml`, look for a duration in minutes rather than seconds) and compare it against the first model-catalog failure. To test a rotated key, dispatch `sync-model-catalog.yml` — it uploads unconditionally.
@@ -52,7 +69,19 @@ renders and preserves every real price; the cheapest in the set is 0.01.
- **No egress to the CDN means no model metadata, permanently and silently.** Air-gapped installs, networks with an outbound allowlist, and CDN outages all fall back to the plain `{ id, name }` row with no message explaining it — every metadata field is optional, so nothing throws. `AP_MODEL_CATALOG_URL` points at a self-hosted mirror and is the only fix. This is a knowing exception to `.claude/rules/self-hosting.md` (decision 000032), so treat "self-hoster says prices are missing" as a network question, not a bug.
- **A new provider needs an entry in the generator's `MODELS_DEV_PROVIDER` map or it silently ships with no metadata.** models.dev provider ids do not match ours: `bedrock` → `amazon-bedrock`, `qwen` → `alibaba`, `moonshot` → `moonshotai`, `activepieces` → aliased onto `openrouter` at lookup time. The six OpenAI-compatible vendors were merged before the map was updated and produced exactly this — the run prints `no upstream source: …`, which is the thing to read after adding a provider. `cloudflare-gateway` and `custom` legitimately have no source.
- **The catalog object must be published before the code that reads it ships.** There is no bundled copy, so until a `workflow_dispatch` run puts it on the CDN, every install — including local dev — shows no metadata at all.
+- **The pricing console shows bundled constants when no file is published, and says nothing.** `overview()` in `ai-pricing-service.ts` has three states, and two of them render the same numbers: **absent** (`NoSuchKey`) silently seeds the editor from `DEFAULT_PRICING_DRAFT`, the console's own checked-in copy of the tiers and weights, while **unreadable** (any other S3 error) shows those same constants *plus* a banner and blocks publishing. Only **present** is live data. So a screen full of plausible tiers is not evidence that anything was ever published — `curl -I https://cdn.activepieces.com/ai/pricing.json` is. The absent state is deliberate, it is how the first publish bootstraps, but it means "I can see the tiers" and "the prices are live" are unrelated facts. Worse, **absent and misconfigured are the same state**: `cdnClient.getJson` catches `NoSuchKey` and returns `undefined`, so a wrong bucket or an endpoint with the bucket in its path reads as "nothing published yet" and hides behind the defaults. The tell is the History tab — `list` does *not* catch `NoSuchKey`, so the same misconfiguration that reads as empty on the pricing page returns a 500 there. A 500 on history with a normal-looking pricing page means the S3 addressing is wrong, not that the history is empty.
+- **Deleting the live tier file is safe for customers and confusing for everyone else.** `ai/pricing.json` was deleted on 2026-09-24 to drop a leftover test tier, and Activepieces kept serving the right tiers throughout: the 404 makes `fetchTiers` throw, the failure is logged and backed off, and `current()` keeps serving the last good copy the process fetched; only a process that never fetched one, a fresh boot during the outage, runs the `ACTIVEPIECES_CHAT_TIERS` shipped with the release. Two things do change. Each server logs 'Failed to refresh the AI model tiers file' (or 'Failed to load…' on a process that never got a copy) on every failed attempt, and attempts happen at boot and whenever `current()` is called with a stale or missing copy, at most once per five minutes (`FAILURE_BACKOFF_MS`), so a permanent 404 is retried the same way as a blip. Today only `warmUp()` calls it, so that is one line per boot. And the console reads the missing key as **absent**, so the next publish starts again at `version: 1` while the `ai/pricing/history/` objects from the old numbering are still there — the version number is `live.version + 1`, not a counter, so it is only meaningful between two publishes that never lost the live key. To remove one bad tier, edit and publish; do not delete the object.
+- **`CDN_S3_*` and `AP_S3_*` are two different buckets on two different providers, and mixing them puts public objects in customer data.** `cdn.activepieces.com` is a **DigitalOcean Spaces** bucket, `activepieces-cdn` in `fra1`, fronted by Cloudflare — a 200 from it carries `x-rgw-object-type` and `x-do-cdn-uuid`, which is how you tell. That is where `ai/model-catalog.json`, `ai/pricing.json` and `sdk/embed/*.js` live, and the credentials are the `CDN_S3_*` GitHub secrets. `AP_S3_BUCKET` is something else entirely: the **Cloudflare R2** file-storage bucket (`ap-files-prod` in production) holding run logs, step checkpoints, webhook payloads and flow bundles. The names are similar enough that the pricing console was once configured with the R2 file-storage key by mistake, which would have written a `public-read` `ai/pricing.json` into the customer-data bucket while `cdn.activepieces.com` stayed 404. When wiring anything that publishes to the CDN, confirm the endpoint is the Spaces region origin (`https://fra1.digitaloceanspaces.com`, never with the bucket in the path — the SDK appends `Bucket` itself and the doubled path returns `NoSuchKey` on every call).
+- **The weekly sync workflow can fail and nobody is told.** `sync-model-catalog.yml` has no alert on failure, and the only signal that it broke is a 404 on the object, which every reader swallows. Its run on 2026-08-31 failed with `InvalidAccessKeyId` — the `CDN_S3_ACCESS_KEY_ID` secret in this repo does not match a live key — so `ai/model-catalog.json` has never actually landed. Check `gh run list --workflow=sync-model-catalog.yml` before believing the CDN is the problem. **That same `CDN_S3_*` credential also publishes the AI tier file `ai/pricing.json` from `ap-analytics`** (decision [000039](../decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md)), so one dead key silently breaks model metadata *and* the published tiers at once: the console hides model costs (`model-catalog-service.ts` logs 'model prices will be hidden' and returns an empty list) while Activepieces keeps serving the tiers bundled in the release. Two features, one credential, no alarm on either.
- **A fresh Cloud platform cannot connect any AI provider, and the UI says nothing about why.** The admin page at `/platform/ai` sets `allowWrite = platform.plan.aiProvidersEnabled`, and that column defaults to **false** on the `free` plan (migration `1776…AddDefaultToAiProvidersEnabled`), so the connect button is simply absent rather than disabled-with-a-reason. On Cloud the flag is owned by Autumn (`autumn-utils.ts` lists it among the synced features), so there is nothing to configure locally and no `DEV_ENTERPRISE_PLAN` escape hatch on main. For local Cloud testing, flip it directly: `UPDATE platform_plan SET "aiProvidersEnabled" = true`. Then connect a **BYO key**, not the managed `ACTIVEPIECES` provider — per *Provider visibility* above, the managed row is hidden whenever `OPENROUTER_PROVISION_KEY` is unset, which is the normal local state, and a hidden provider makes `getChatProvider()` return null. Anything gated on a chat provider (chat itself, personalization research) stays silently disabled until a BYO row exists.
+- **Since the AI steps moved to the worker, the server sees every step's model id, and `ai-execute-controller` is the one place to act on it.** The piece POSTs `/v1/ai/execute` with `provider` and `modelId`, the controller builds the `EXECUTE_AI` job from that body, and the worker runs whatever model id the job carries. So anything that needs to rewrite or validate the model a flow step is about to use belongs there, not in the piece — one server-side edit covers all five AI steps, and an old pinned piece bundle keeps working because it only forwards the string it stored. This is what makes storing a tier id in a step cheap: the bundle never has to understand it. Note the agent step does **not** come through here — it resolves separately over `getAgentConfig`, in `agent-config-rpc`. Two conditions sit under that. **An old bundle only forwards the string from `0.11.0` up** — `AI_PIECE_COST_BILLING_VERSION`, the first version whose `ai-step.ts` POSTs `v1/ai/execute`. A step pinned below it runs the pre-worker code that calls the provider from the sandbox, so a tier id there would reach OpenRouter as the literal string. That case is already closed: `migrate-v26-ai-piece-cost-billing` bumps every saved `piece-ai` step below `0.11.0` up to it, which is why a later migration can rewrite what a step stores without touching `pieceVersion` for the runtime. **And resolve on the API, never in the worker**: `ExecuteAiRequest.modelId` is a bare `z.string()` so a tier id passes validation, `getEffectiveProviderAndModel` and `aiUtils.buildWebSearchToolsOrThrow` both read `data.modelId` as a real model, and the `resolveAiProvider` RPC does not carry the model at all, so using it as the seam would mean widening the worker contract.
+- **Managed billing can only price an OpenRouter call, and a call it cannot price is simply not billed.** `openRouterCostOf` (`server/utils/activepieces-ai-cost.ts`) reads `providerMetadata['openrouter'].usage.cost`, with one fallback that GETs `openrouter.ai/api/v1/generation?id=` and takes `total_cost`. If neither answers, `createBillingInfo` returns `undefined` and the turn is never reported — it runs, the customer keeps the output, and nothing is charged. Nothing logs "unbilled". This is what actually welds the managed provider to OpenRouter, more than the client does: `create-language-model.ts` shares one branch for `OPENROUTER` and `ACTIVEPIECES`, every tier `modelId` is an OpenRouter id, and `openrouter-api.ts` mints the per-platform key from `OPENROUTER_PROVISION_KEY`. So putting managed credits on any other gateway needs a second cost extractor and a second generation lookup **first**, or every call through it bills zero. For reference, no Vercel AI Gateway code exists here at all — the only gateway in the repo is Cloudflare's (`CLOUDFLARE_GATEWAY`, the `ai-gateway-provider` package), and `@ai-sdk/gateway` appears only as a transitive dep of `ai` that nothing imports. A BYOK user can already reach Vercel's gateway today through `AIProviderName.CUSTOM`, which takes a base URL plus hand-typed model ids.
+- **A tier id that no longer exists resolves two different ways, and only the run source decides which.** `getAgentConfig` branches on `namesItsOwnModel` (true for `AgentRunSource.FLOW_STEP` and `AGENT`): those go through `resolveNamedModelId`, which **throws** `The model "X" is not available on Activepieces AI credits` for an id it cannot find, while every other source goes through `resolveModelIdForProvider` → `resolveTier`, which **silently returns the default tier**. So deleting a published tier moves existing chats onto the default with no error and no notice, and that is genuinely safe only while flow steps and agents still store concrete model ids. The moment a step stores `premium` it takes the throwing branch and every run of that flow fails, because the candidate list is model ids and never contains a tier id. Whoever lands the tier-id switch has to decide what a removed tier means before the console keeps telling admins that removing one is safe.
+- **The console's Publish writes the live key directly; there is no draft, no staging and no filter.** `ai-pricing-service.ts` in `ap-analytics` writes `LIVE_PRICING_KEY` on Publish, and its "add tier" row seeds `id: tier-N`, `label: New tier`, so a half-finished experiment is one click from every server's cached tier list, with only the CDN `max-age=300` plus the 15-minute in-process TTL in between. Test against a local file with `AP_MODEL_TIERS_URL` (add the host to `AP_SSRF_ALLOW_LIST` for loopback), never by publishing.
+- **The AI piece's managed dropdown only ever offers tier models, so every stored managed model id came from a tier pick.** `props.ts` (`packages/pieces/community/ai/src/lib/common/props.ts`) builds its options from `ACTIVEPIECES_CHAT_TIERS`, the constant compiled into the release, in two places: `managedModelLabel` supplies the label, and the same lookup is the `.filter` that decides which models are offered at all. There is no way to pin a model deliberately through it. Two consequences. A re-pointed tier is invisible to the AI piece in both directions: a saved step keeps its concrete model id, and a new step cannot pick the new model because it matches no bundled tier. And any migration from stored model ids to tier ids has a trivially complete mapping, since each stored id is one of the three bundled tier model ids of the release it was saved on. Until the dropdown emits tier ids, the AI piece only ever follows the release.
+- **`AIProviderModel` is declared twice, and a field added to one half is stripped on the wire.** There is one in `core/piece-types` (what pieces import, since they cannot import `@activepieces/shared`) and one in `core/shared` (what the server and the response schema use). They are not re-exports of each other — today the shared copy carries `metadata` and the piece-types copy does not. Fastify serialises the response against the *shared* schema and drops every key it does not declare, so adding a field to the piece-types copy alone type-checks in the piece, type-checks in the server, and arrives as `undefined`. A published tier label was nearly shipped that way. Any new field on a provider model needs both files.
+- **No image model belongs to a chat tier, so a tier-based filter empties the image picker.** The managed provider is OpenRouter underneath and `openrouter-provider.listModels` tags a model `IMAGE` when its `architecture.output_modalities` says so — `ACTIVEPIECES` is deliberately absent from `NO_IMAGE_GENERATION_PROVIDERS`, and `google/gemini-2.5-flash-image` and friends are real managed models. But `ACTIVEPIECES_CHAT_TIERS` is three *chat* tiers, so no image model can ever match one. Any filter of the form "a managed model must belong to a tier" therefore drops every image model silently, leaving `aiProps({ modelType: 'image' })` with an empty dropdown and no error. Gate that filter on `modelType === 'text'`.
+- **`premium` pairs `modelId: anthropic/claude-opus-4.8` with `nativeModelId: claude-opus-4-7` on purpose.** The managed tier runs Opus 4.8 through OpenRouter; a customer's own Anthropic key runs Opus 4.7. The reason is cost on the customer's key: Heavy on a BYOK key must not silently move the customer onto the most expensive model without them choosing it. The console's `defaults.ts` carries the same pair. Do not "fix" either copy.
- **Every provider auth schema is written twice, and only one copy is the one the server reads.** `packages/core/utils/src/lib/ai-provider-credentials.ts` (plain zod) backs the API and the web app; `packages/core/piece-types/src/lib/ai-providers.ts` (`zod/mini`, so `.check(z.minLength(1))` and `z.optional(x)` instead of `.min(1)` / `.optional()`) is what `@activepieces/pieces-framework` re-exports to pieces and the engine. Adding a credential field to only one leaves the other silently stripping it — zod objects drop unknown keys, so the upsert still returns 200 and the failure surfaces later as a provider-side auth error. Change both.
- **A schema change in `packages/core/utils` is invisible to another package's tests until you build it.** Sibling packages resolve `@activepieces/core-utils` through its `main` (`dist/src/index.js`), so vitest in `core/ai-providers` keeps parsing against the stale build and a new optional field reads back as dropped. `cd packages/core/utils && npm run build` first. The API dev server is the opposite: `serve` runs under tsx with the tsconfig path alias, so it reads `src` directly — but it only watches `packages/core/shared/src/**`, not `core/utils`, so a core-utils-only edit needs a restart to take effect. The same staleness makes a **branch** look green when it is not: `dist` is gitignored, so after `git checkout` it still holds whatever the previous branch built, and a test that depends on a field added by a sibling PR passes locally and fails in CI. Rebuild `core/utils` after every branch switch before trusting a run.
@@ -84,7 +113,7 @@ renders and preserves every real price; the cheapest in the set is 0.01.
- **The OpenAI-compatible provider can speak the Responses API, and `@ai-sdk/openai-compatible` is not how you get there.** That SDK exposes only `chatModel` (`/chat/completions`) and `completionModel` — no responses model in any version. The route is `@ai-sdk/openai`, which accepts a custom `baseURL` + `headers` and exposes `.responses()`; the Cloudflare gateway's openai branch already did this before the `apiStyle: 'chat' | 'responses'` config field existed. Header precedence is the subtlety: the SDK builds `{ Authorization: Bearer , ...options.headers }`, so a caller header wins on `Authorization` (the Bedrock case, where the key *is* the bearer token) but a custom `apiKeyHeader` such as `x-api-key` leaves the SDK's default `Authorization` riding along. `withUserAgentSuffix` drops `undefined` values, but `OpenAIProviderSettings.headers` is typed `Record`, so the way to strip it is the header-stripping `fetch` the Cloudflare branch uses — no cast needed, and worth reaching for straight away rather than writing the duplicate header up as an accepted limitation. Strip only when `apiKeyHeader` is not itself Authorization, compared **case-insensitively**: HTTP header names are, and an admin can type `authorization`. Confirmed shape, since it is easy to assert on the wrong key: the SDK lowercases everything, so a mis-set provider really does emit `{authorization: 'Bearer ', 'x-api-key': ''}` — the credential twice.
- **AWS's OpenAI-compatible surface is two endpoints, and "Bedrock Mantle" is a real AWS product name, not a customer's nickname.** `bedrock-runtime.{region}.amazonaws.com/openai/v1` (AWS-recommended) and `bedrock-mantle.{region}.api.aws/v1` both serve Chat Completions *and* Responses, both authenticate with a Bedrock API key as `Authorization: Bearer`, and both take an unmodified OpenAI SDK. Mantle alone adds server-side tools and web search, `background=true` async inference, and Projects/Workspaces; bedrock-runtime alone has Guardrails, cross-Region inference and intelligent prompt routing. So a ticket claiming "Mantle only supports responses" is half right — it serves chat/completions too, and the real loss is the Responses-only capabilities.
- **Vertex is a different door onto the same models, not a different vendor.** The `GOOGLE` provider is the Gemini Developer API (`generativelanguage.googleapis.com`, static API key); `VERTEX` is Vertex AI (`{region}-aiplatform.googleapis.com/v1beta1/projects/{project}/locations/{region}/publishers/google`, service-account OAuth2 with ~1h tokens) — the same Gemini models, but billed to the customer's GCP account and inheriting IAM, VPC-SC, CMEK, data residency and audit logs, plus Model Garden's Claude/Llama/Mistral. That rotating token is why the CUSTOM provider can never reach Vertex: `buildOpenAICompatibleHeaders` injects one static header, which is why the workaround used to be a LiteLLM proxy. `@ai-sdk/google-vertex` does the JWT-to-token exchange itself, so the native provider needs no proxy. Vertex models are entered by hand (`MANUAL_MODEL_PROVIDERS`) on purpose: availability is project-, region- and Model-Garden-specific, and a `publishers/google` listing would miss exactly the third-party models that motivate choosing Vertex. **But "same models" only holds for Gemini.** Model Garden also serves Anthropic, Meta, Mistral and xAI, and those do not share Gemini's API surface — `@ai-sdk/google-vertex` ships separate `/anthropic`, `/maas` and `/xai` entry points for exactly that reason, so one `createVertex(...)` call does not cover Vertex. Because models are typed in by hand, a Claude id reaches the factory as an ordinary string and, unrouted, gets built with the Gemini client and fails at the endpoint. `create-language-model.ts` routes ids containing `claude` to `createVertexAnthropic`; `vertexClientFor` picks the client from the id shape, so all three are routed. Because the shapes are unambiguous the check is a lookup, not a tuned heuristic — and note the publisher prefix has to be tested *before* the `claude` one, or a MaaS path containing "claude" routes to the Anthropic client. Routing is cheap because the id shapes are distinct, not because a heuristic was tuned: `gemini-2.5-pro` is bare, Anthropic carries an `@date` (`claude-3-5-sonnet@20241022`), and MaaS is publisher-prefixed and suffixed (`meta/llama-4-scout-17b-16e-instruct-maas`). The publisher prefix is a stronger discriminator than the `includes('claude')` match currently shipped, and it looks like an argument for listing models rather than taking them by hand — but it is not, see below: the listing is scoped to a single publisher, so Model Garden ids stay hand-typed and the string matching stays with them. **Listing was considered and rejected**, and the reason is worth keeping so it does not get reopened, and **the response shape does not need a GCP account to learn** — every Google API publishes an unauthenticated discovery document: `curl -s 'https://aiplatform.googleapis.com/$discovery/rest?version=v1beta1'` returns ~5MB of JSON whose `schemas` are the authoritative request/response types. `publishers.models.list` returns `{ publisherModels: PublisherModel[], nextPageToken }`, and `PublisherModel` is `{ name: 'publishers/google/models/', versionId, versionState (STABLE|UNSTABLE), launchStage (GA|PUBLIC_PREVIEW|PRIVATE_PREVIEW|EXPERIMENTAL), openSourceCategory, supportedActions, predictSchemata, frameworks, parent }`. **There is no `displayName` on it** — the one in `google-vertexai`'s piece comes from `@google/genai`'s `Model` type, which is a different shape, so copying that code gives every row a blank label with nothing failing. `displayName` exists only on the nested `parent`, which is the base model a tuned model derives from. The decisive fact is in that same doc: `publishers.models.list` takes `parent` matching `^publishers/[^/]+$` — **one publisher per call**. Listing everything therefore means hardcoding the set of publishers to enumerate, which is the same shape of hardcoding it was meant to remove, and Model Garden models would still need hand entry, leaving two mechanisms where there is now one. `VERTEX` stays in `MANUAL_MODEL_PROVIDERS` alongside `CUSTOM` and `CLOUDFLARE_GATEWAY`, which fits: Vertex model availability really is per-project. Reach for the discovery doc before a live probe on any `*.googleapis.com` integration; a curl against the real endpoint needs **billing attached to the project even for a read** (`403 BILLING_DISABLED`), so it is the slower path and it is not free to set up. The `x-goog-user-project` header is not optional when probing with a personal login: without it aiplatform answers `403 SERVICE_DISABLED` naming `consumer: projects/32555940559`, which is Google's shared gcloud CLI project rather than yours, and the message talks about quota projects rather than the missing header. This is a user-credential quirk only — a service account carries its own `project_id`, so the provider itself never hits it. **The multi-vendor story is text only.** Vertex's image models are Google's own Imagen (`imagen-3.0-*`, `imagen-4.0-*`), not Model Garden's third parties, and `createVertex(...)` also exposes `imageModel`, `video`, `speech`, `transcription` and `textEmbeddingModel` — none of which we wire today. Imagen is wired (`createVertex(...).imageModel()`, with a `VERTEX` case in the piece's image switch, so `VERTEX` is no longer in `NO_IMAGE_GENERATION_PROVIDERS`); video, speech, transcription and embeddings are not. Embeddings still have no `DEFAULT_EMBEDDING_MODELS` entry, so that path paths fail with an explanatory message instead of reaching a switch that has no case for it.
-- **Chat-tier resolution answers "which model" from three places, and only one of them is the key.** `resolveModelIdForProvider` (`ee/agent/agent-helpers.ts`) now prefers the resolved key's own configured text models when its config carries a `models` array — that is the `MANUAL_MODEL_PROVIDERS` case (Vertex, Custom, Cloudflare Gateway), where the admin typed the exact ids the key exposes. **An empty catalog is not a missing one**, and collapsing the two is how the first cut of this went wrong: a helper returning `string[] | undefined` treated "lists zero text models" the same as "has no catalog", so a key configured with only image models fell through to the curated list and resolved to a Gemini id it never offered. An admin-listed catalog is the whole truth about a key, so an empty one now refuses the turn with a message rather than guessing. Otherwise it falls back to the static `ALLOWED_CHAT_MODELS_BY_PROVIDER`, and if the provider is missing from that too it returns the raw tier id with its vendor prefix stripped — which is how a Vertex key once resolved to `claude-sonnet-4-6` and handed it to the Gemini client. That map is `Partial<`, so omitting a chat-capable provider compiles cleanly and fails only at the endpoint. **The key's own `modelScope`/`modelIds` allow-list is applied last, to whichever candidate list was chosen**, and a resolution with nothing left refuses the turn rather than returning a model the key forbids. `GetProviderConfigResponse` carries both fields for that, populated at all three construction sites (`getChatProvider`, `getConfigOrThrow`, `enrichWithKeysIfNeeded`); adding fields there is backward compatible because the AI piece reads the response as a typed shape rather than parsing it. I first recorded this as too big for a provider PR — "a wire contract the engine and the AI piece both consume" — having never measured it. It was about thirty lines across four files. Measure before recording something as out of scope; the estimate outlives the guess. **The resolver is exported and has five call sites across four files, and only two of them are runs.** `agent-rpc-handlers` resolves the chat turn's model *and* its fast model, and `chat-personalization-service` resolves both for research — those four must be given the resolved row's `config`, `modelScope` and `modelIds`, or the key's constraints are bypassed on exactly the paths a user takes. `resolveTierModel` inside `agent-helpers` is the fifth. `agent-service` and `agent-draft-ai` also call it but hold only a provider name and set a stored default on a draft, so they are correctly left thin. Adding a parameter here means grepping `resolveModelIdForProvider|resolveFastModelId` across `packages/server`, not editing the call site in front of you — threading only `resolveTierModel` looks complete, passes every test, and still leaves the real chat path unconstrained. One trap when touching this function: a tier id (`fast`, `smart`) is **not** a model id, so the preferred pick must be the resolved native id unless the selection is itself among the candidates — passing `selectedModel` straight through makes `resolveFastModelId` return the first curated model instead of the fast tier's.
+- **Chat-tier resolution answers "which model" from three places, and only one of them is the key.** `resolveModelIdForProvider` (`ee/agent/agent-model-resolution.ts`, spread into `agentHelpers`) now prefers the resolved key's own configured text models when its config carries a `models` array — that is the `MANUAL_MODEL_PROVIDERS` case (Vertex, Custom, Cloudflare Gateway), where the admin typed the exact ids the key exposes. **An empty catalog is not a missing one**, and collapsing the two is how the first cut of this went wrong: a helper returning `string[] | undefined` treated "lists zero text models" the same as "has no catalog", so a key configured with only image models fell through to the curated list and resolved to a Gemini id it never offered. An admin-listed catalog is the whole truth about a key, so an empty one now refuses the turn with a message rather than guessing. Otherwise it falls back to the static `ALLOWED_CHAT_MODELS_BY_PROVIDER`, and if the provider is missing from that too it returns the raw tier id with its vendor prefix stripped — which is how a Vertex key once resolved to `claude-sonnet-4-6` and handed it to the Gemini client. That map is `Partial<`, so omitting a chat-capable provider compiles cleanly and fails only at the endpoint. **The key's own `modelScope`/`modelIds` allow-list is applied last, to whichever candidate list was chosen**, and a resolution with nothing left refuses the turn rather than returning a model the key forbids. `GetProviderConfigResponse` carries both fields for that, populated at all three construction sites (`getChatProvider`, `getConfigOrThrow`, `enrichWithKeysIfNeeded`); adding fields there is backward compatible because the AI piece reads the response as a typed shape rather than parsing it. I first recorded this as too big for a provider PR — "a wire contract the engine and the AI piece both consume" — having never measured it. It was about thirty lines across four files. Measure before recording something as out of scope; the estimate outlives the guess. **The resolver is exported and has five call sites across four files, and only two of them are runs.** `agent-rpc-handlers` resolves the chat turn's model *and* its fast model, and `chat-personalization-service` resolves both for research — those four must be given the resolved row's `config`, `modelScope` and `modelIds`, or the key's constraints are bypassed on exactly the paths a user takes. `resolveTierModel` inside `agent-helpers` is the fifth. `agent-service` also calls it but holds only a provider name and sets a stored default on a draft, so it is correctly left thin (`agent-draft-ai` did too, until #15676 deleted it). Adding a parameter here means grepping `resolveModelIdForProvider|resolveFastModelId` across `packages/server`, not editing the call site in front of you — threading only `resolveTierModel` looks complete, passes every test, and still leaves the real chat path unconstrained. One trap when touching this function: a tier id (`fast`, `smart`) is **not** a model id, so the preferred pick must be the resolved native id unless the selection is itself among the candidates — passing `selectedModel` straight through makes `resolveFastModelId` return the first curated model instead of the fast tier's.
- **A failed credential validation tells the admin nothing, for every provider except Cloudflare Gateway.** `aiProviderService.validateProviderCredentials` gates the upstream message behind `includeHttpErrorInMessage`, which is `provider === CLOUDFLARE_GATEWAY` and nothing else, so everyone else gets a bare `Failed to validate credentials for `. The cause is not lost — it is logged one line earlier (`log.error({ error }, '[aiProviderService#validateProviderCredentials] ...')`) and passed as the `httpErrorResponse` error param — but **web never renders `httpErrorResponse`**, so the only way to diagnose a rejected key is the server log. Grep the log for `validateProviderCredentials` before assuming the provider integration is broken — that text is the whole diagnosis, and it is often not about credentials at all. Confirmed case: a brand-new xAI team with no credits purchased answers `GET /v1/models` with `403 permission-denied — Your newly created team doesn't have any credits or licenses yet`, naming the console page that fixes it, and we render that as "Failed to validate credentials for xAI" — sending the admin off to regenerate a key that was never wrong. Vendors also phrase real key failures inconsistently (xAI uses `400 Incorrect API key provided`, not a 401). The corollary: **a provider that saves without error is not a working provider.** A no-credits 403 and a bad key are indistinguishable in the UI, so only an actual generation proves a key end to end. This is an admin-only surface (`platformAdminOnly`), so there is little reason to keep hiding it.
- **A provider's logo is an asset someone has to upload, not something the code ships.** `AiProviderInfo.logoUrl` in `packages/web/src/features/agents/ai-providers.ts` is a plain string rendered into an ` `, and every provider points at `https://cdn.activepieces.com/pieces/.png` — nothing is bundled. Adding a provider therefore carries a cross-team dependency with no compile-time or test signal: a slug with no asset behind it renders a broken-image icon in the platform admin list, and only a live request tells you. Check the URL with `curl -o /dev/null -w '%{http_code}'` before assuming it works — a vendor that already ships as a *piece* usually has its logo there already (`deepseek.png`, `grok-xai.png` did), so start the upload request only for the genuinely missing ones. A Vite asset import also satisfies `logoUrl` (see `GoogleIcon` in `platform/security/sso/index.tsx`) and removes the runtime CDN dependency for air-gapped installs, but it diverges from every other provider — treat it as a fallback, not the default.
- **The manual-models panel stores a model id and nothing else, so every model an admin adds is typed `TEXT`.** `MANUAL_MODEL_PROVIDERS` (Vertex, Custom, Cloudflare Gateway) persist a `ProviderModelConfig` of `{ modelId, modelName, modelType }`, but since #14825 the UI keeps only `string[]`: `ManualModelList` is a chip list of ids, and `config-detail.tsx` rebuilds the objects on save with `?? { modelId, modelName: modelId, modelType: TEXT }`. The old `universal-pieces/model-form-popover.tsx` had all three fields; replacing it with chips dropped two of them, and no schema or test noticed because the reconstruction is type-correct. The visible damage is not just a mislabelled model: `cloudflareGatewayProvider.validateConnection` pings **only text models** with a `/chat/completions` request, so one image model added through the UI is validated as chat, fails, and takes the whole key down with `These models have issues: ...` — the admin cannot save a key that has an image model on it at all. Anything reading `modelType` downstream (the agent text-model filter, `ap-list-ai-models`, the builder's image pickers) is wrong the same way. Fixed by holding `ProviderModelConfig[]` in the draft rather than `string[]` (no migration; stored rows always had the right shape), so the standing rule is that **a manual-models UI must carry `modelType` all the way to the payload** — a panel that collapses models to ids reintroduces exactly this, and nothing downstream will complain until an admin adds an image model. Keys saved while the bug was live still hold image models typed `TEXT`; the type badge on each chip flips one in place, which is why it is editable rather than display-only.
diff --git a/brain/knowledge/ai-intelligence/index.md b/brain/knowledge/ai-intelligence/index.md
index 88d81a4e6e82..1fdb7365f62c 100644
--- a/brain/knowledge/ai-intelligence/index.md
+++ b/brain/knowledge/ai-intelligence/index.md
@@ -13,9 +13,18 @@ A configured LLM backend (OpenAI, Anthropic, Google, Azure, OpenRouter, Cloudfla
The metered currency for AI usage — one credit is `AP_AI_CREDIT_USD_VALUE` of model spend, $0.0005 by default — backed by per-key OpenRouter limits. A quota, not a wallet. A managed call is billed on the dollar cost the provider reports; a call on a customer's own key is billed one flat credit. See [decision 000037](../decisions/000037-ai-is-billed-on-what-the-call-cost-observed-at-the-worker.md).
- *Avoid:* "tokens" for the billing unit; tokens are the model's unit, credits are ours. *Avoid:* "credit weight" — the per-model weight table is gone.
+### 🎚️ Model Tier
+A named slot, Fast, Expert or Heavy, that a customer picks instead of a model, so we can move it to a newer model without a deploy. It carries `id`, `label`, `modelId`, an optional `nativeModelId` for a customer on their own key, and a `thinkingBudget`. Tiers are published to the CDN by the console (decision [000041](../decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md)) in two lists and fall back to `ACTIVEPIECES_CHAT_TIERS` in the release. Tier ids are `fast` / `smart` / `premium`; only `fast` matches its label (`smart` is Expert, `premium` is Heavy), so match on `id`, never the label. A tier id never contains `/`; a managed model id always does.
+- **Flow tiers** — the `tiers` list; what FLOW_STEP and AGENT resolve against, the surfaces that name their own model. **Chat tiers** — the `chatTiers` list; what CHAT and AGENT_BUILDER resolve against. The chat list is optional and falls back to the flow list. In code the pair is `ModelTierSurface = 'chat' | 'flow'`.
+- *Avoid:* "chat tier" for a model tier in general; say "model tier", and "chat tiers" only for the chat list. The other real axis is text vs image (`ACTIVEPIECES_IMAGE_TIERS`).
+
### 🤖 Agent
A flow step that runs an autonomous LLM loop rather than a single call. Its **AgentTool**s are Piece, Flow, MCP, or Knowledge Base handles.
+### 🚪 Run source
+The door an agent turn came through. `AgentRunSource` is CHAT (the assistant chat, an agent turn with no agent row), AGENT (chatting with a saved agent), AGENT_BUILDER (the chat that builds an agent) and FLOW_STEP (the `run_agent` action). All four create an `agent_conversation` row and run the same `EXECUTE_AGENT_RUN` worker job; the source decides the tool set, the system prompt and which tier list the model resolves against. See the four-doors table on the AI Agents page.
+- *Avoid:* "surface" for the door itself; surface is the two-way `chat | flow` split the tier lists use, and two doors map onto each.
+
### ⚡ Direct AI step
A flow step that makes one model call and returns: Ask AI, Summarize Text, Classify Text, Extract Structured Data, Generate Image. The counterpart to an **Agent**, which loops — and the distinction decides how each reaches a model, see [decision 000035](../decisions/000035-direct-ai-steps-reuse-the-agents-suspend-and-resume-path.md).
- *Avoid:* "simple AI action" and "AI action" — both name the piece rather than the behaviour, and would wrongly include the Agent step, which also lives in the AI piece.
diff --git a/brain/knowledge/ai-intelligence/mcp-server.md b/brain/knowledge/ai-intelligence/mcp-server.md
index d28cb69017b5..b275858e7ffa 100644
--- a/brain/knowledge/ai-intelligence/mcp-server.md
+++ b/brain/knowledge/ai-intelligence/mcp-server.md
@@ -49,6 +49,13 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop,
its next refresh, which backfills a NULL key), and `NULL` is not a third state — it means "signed in before the
column existed" and reads as `unknown` everywhere, including the `?clientKeys=unknown` filter.
- **Revocation only reaches a live access token through the `grantId` claim, and a token without one is trusted.** `revoked = true` is read by the refresh flow, never by `POST /mcp`, so the Revocation List is what actually cuts a connected client off — see [000033](../decisions/000033-a-revoked-mcp-grant-is-blocked-by-a-redis-revocation-list.md). It is keyed on `grantId` (the `mcp_oauth_token` row id) because `clientId` is a DCR registration shared across users, so revoking by it would kill strangers' tokens. Two populations carry no claim and therefore skip the check entirely: tokens minted before the claim shipped (they age out in 15 minutes, and `refreshAccessToken` stamps the claim) and `issueInternalAccessToken` tokens for EE chat, which have no grant row at all. Anything that mints an MCP access token without a grant row inherits that exemption silently. Platform teardown is a third gap by design — it `DELETE`s the rows instead of revoking them, so no keys are written.
+- **The platform `disabledTools` list is the outer bound on every MCP server; the project list narrows it.** A project can switch off more than the platform, never switch back on what the platform switched off.
+ A platform client picks its project after it connects, so `executeInSelectedProject` checks the selected project's live list at call time: a switched-off tool stays listed, refuses when called, and is not charged.
+ `LOCKED_TOOL_NAMES` are exempt from both lists; `isToolEnabled` in `mcp-server-builder.ts` is the one place that rule lives.
+- **The MCP page has no Pieces tab: pieces are the second segment of Tools.** Built-in and Pieces share one project picker, because both answer "what can a connected client call in this project". The segment rides the URL as `?segment=`, and the old `/mcp-server/pieces` address is its own route, `LegacyPiecesRedirect`, that keeps `?project=`. Flows-as-tools and the project `/mcp` URL live only in the embedded MCP settings dialog.
+- **The Tools tab edits the selected project's `disabledTools`, not the session project's.** It posts to `POST /v1/projects/:projectId/mcp-server` with the picker's project, so the role that matters is the role there: `McpTools` reads `WRITE_MCP` through `useAuthorization(projectId)` and disables every checkbox without it. `platformDisabledTools` comes back beside the project list, and `McpTools` shows those tools as off, locked and badged, here and in the embedded dialog. `useMcpNav` drops a `?project=` that is not an `ApId`, because `..` survives `encodeURIComponent` and the browser would resolve the path to the platform server's own row.
+- **The four `PLATFORM_LEVEL_TOOL_NAMES` never reach `executeInSelectedProject`, so they carry their own permission gate.** `ap_research_pieces`, `ap_search_actions`, `ap_search_triggers` and `ap_get_piece_props` run against `templateMcp` (projectId = platformId) and return early in `registerPlatformTools`, which means the per-project `PermissionChecker` never touches them. Membership is earned by guarding on `mcpUtils.isProjectScoped(mcp)` and passing `undefined` instead of the fake project id — `ap_list_ai_models` was listed here without that guard, so `rowAllowsScope` tested the platform id against an AI provider row's `projectIds`, hiding `selected`-scoped providers from everyone and offering `except`-scoped ones to the very projects they exclude. It routes through the selected project now. Without a gate of their own a member whose `READ_MCP` was revoked everywhere kept calling them on an already-issued token, and kept being charged for it. `withMcpReach` now wraps them with `mcpAccess.hasMcpReach`, the same any-project rule `POST /v1/mcp-oauth/approve` applies at authorization time, and it wraps the `charged` call so a refusal costs nothing. `ap_set_project_context` is wrapped the same way; enforcing the rule from inside its `execute` alone left the denial on the far side of `charged`, so a user with reach nowhere paid a credit per refusal.
+- **`READ_MCP` gates external MCP clients, not the in-app chat.** The chat reaches its tools through `POST /mcp/platform` like any client, and the controller serves it through the project branch once the conversation resolves a project, so `resolveMcpPermissionChecker` would deny every chat tool to a custom role with MCP set to None. `isInAppChatIdentity` in `mcp-oauth.controller.ts` picks the chat out as a *platform-scoped* token carrying `INTERNAL_CHAT_CLIENT_ID`, and `buildMcpServer` then uses `resolvePermissionChecker` (per-tool checks only, as the chat's own `agent-tools.ts` path does). Both halves are needed: `POST /v1/projects/:id/mcp-server/token` mints the same client id, but always project-scoped, and `resolveIdentity` rejects a scope mismatch, so those embed-SDK tokens keep the gate. Only the project branch relaxes; `registerPlatformTools`, `withMcpReach` and `executeInSelectedProject` stay gated, because the platform branch is the fallback for a conversation with no project or one the user lost access to.
- **Claude Code and Codex re-run Dynamic Client Registration on *every* sign-in**, registering the exact
ephemeral loopback port they are about to bind (`http://localhost:/callback`,
`http://127.0.0.1:/callback/`). So the exact-string `validateRedirectUri` works and
@@ -101,6 +108,12 @@ Exposes an Activepieces project as an MCP server so AI clients (Claude Desktop,
- **The Pieces tab's search is server-side, and it only works because `pieceDisplayName` is a Fuse key.** `/v1/pieces?searchQuery=` replaces each piece's `actions` with the matched subset (`searchForSuggestion`), which sounds fatal for a page that shows a per-piece action count and a destructive badge — but `searchForSuggestion` searches `['pieceDisplayName', 'displayName', 'description']`, so querying a *piece* name matches every action inside it and the row still lists the lot. Two more things make it safe: `toPieceMetadataModelSummary` computes `summary.actions` from the pre-search `audiencePieces`, so the total count is never narrowed by a query, and the tab force-expands every row while searching, so the count it renders is visibly the list beneath it. Keep the popular-first sort for the unsearched view only — applying it to search results throws away Fuse's relevance ranking. Rows are still grouped and counted client-side in `piecesUtils.toReachablePieces`, which is a pure function with its own unit test.
+- **The MCP page is gated on MCP *reach*, not on the session project, and `RoutePermissionGuard` cannot express that.** The server rule is "`READ_MCP` in at least one project" — what `POST /v1/mcp-oauth/approve` and `withMcpReach` both enforce — while `checkAccess` resolves the session project alone, so a member holding `READ_MCP` in project B while sitting in project A could use the platform server but got a 404 on the page handing out its address, and no MCP entry in the rail. `GET /v1/mcp-server/reach` answers the right question in one call; nothing else could, because `GET /v1/projects` carries no role or permission field and `listProjectIdsWithPermission` had no HTTP surface. It is the one `publicPlatform` route on a controller whose other three are `platformAdminOnly`, and it returns project ids only. A privileged caller gets `projectIds: null` rather than an enumeration of the platform — `getAllForUser` skips its filter for them, so listing every id would load every project row on a large platform, and both readers, `McpReachGuard` and the `ProjectPicker` filter, already read `null` as "every project". An embedded user is refused in the handler, through `userIdentityHelper.isUserEmbedded`, the same way `POST /v1/mcp-oauth/approve` refuses them the platform-wide branch — their `project_member` rows are never removed, so the id list would span every tenant they ever signed into. The refusal cannot be a route-level `securityAccess.nonEmbedUsersOnly`: despite the name, that helper also demands `WRITE_INVITATION` on some project, so it would refuse the very members this endpoint exists for. Treat it as auxiliary: it fails open, because an outage on it must not lock people out of a page the server would have opened.
+
+- **MCP never lists or authorizes another user's personal project, even for an admin or operator.** `getAllForUser` returns every project of the platform to a privileged user and `getRole` gives an operator EDITOR on each, but `mcp-access.ts` drops a `PERSONAL` project unless the caller owns it, in both `listAccessibleProjects` and `hasMcpAccessToProject`. It matches `getUserProjects` in `ee/agent/agent-helpers.ts`, so the in-app chat and an external client see the same projects. `GET /v1/mcp-server/reach` still answers `null` for a privileged caller; its readers filter `/v1/projects`, which already hides those projects.
+
+- **`mcp-access.ts` is CE code that imports two EE modules, and that is deliberate for now.** It reaches `ee/authentication/project-role/rbac-middleware` and `ee/projects/project-members/project-member.service`, against `.claude/rules/edition-safety.md`. It is safe at run time only because every EE call sits behind `editionRequiresRbac()` and `repoFactory` is lazy, so on Community nothing is touched. `mcp-permissions.ts` opened this breach before `mcp-access.ts` widened it. Moving it behind a `hooksFactory` is the correct fix and is its own change — do not half-do it while editing something else here.
+
### Key files
Entry point: `mcpServerModule`, the Fastify plugin in `mcp/mcp-module.ts` registered from `packages/server/api/src/app/app.ts`.
@@ -110,7 +123,7 @@ Entry point: `mcpServerModule`, the Fastify plugin in `mcp/mcp-module.ts` regist
- `packages/server/api/src/app/mcp/oauth/` — OAuth 2.0 PKCE flow: metadata, authorize, token, revoke
- `packages/core/shared/src/lib/automation/mcp/` — McpServer schema, McpToolDefinition, MCP OAuth types
- `packages/web/src/app/components/project-settings/mcp-server/` — settings panel: credentials, flows-as-tools, tool toggles
-- `packages/web/src/app/routes/mcp-server/` — the Connect, Pieces and Grants tabs
+- `packages/web/src/app/routes/mcp-server/` — the Connect, Tools, Connections and Activity tabs; Tools holds the Built-in / Pieces segments
- `packages/web/src/app/routes/mcp-authorize/` — standalone OAuth consent page and its permission item
- `packages/web/src/app/routes/embed/` — the `embedded-mcp-*` dialogs for managed-auth consent and settings
- `packages/ee/embed-sdk/src/index.ts` — embed SDK public methods `authorizeMcp()`, `mcpSettings()`, `generateMcpToken()`
diff --git a/brain/knowledge/connections-auth/app-connections.md b/brain/knowledge/connections-auth/app-connections.md
index 8c3af0820df6..ac051b335e6c 100644
--- a/brain/knowledge/connections-auth/app-connections.md
+++ b/brain/knowledge/connections-auth/app-connections.md
@@ -43,3 +43,7 @@ Entry point: `appConnectionService`, exported from the app-connection service an
- `packages/web/src/app/routes/platform/setup/connections/` — platform-wide global connections page
Paths verified 2026-07-17.
+
+## Gotchas
+
+- **`needRefresh` looks the piece up by `connection.pieceVersion`, so a CUSTOM_AUTH connection whose stored version is no longer served fails as `ConnectionNotFound`, not as a version error.** Any 404 from `GET /v1/worker/app-connections/:externalId` is mapped by the engine to "connection not found", which points debugging at credentials. On a dev instance this happens the moment a dev piece's version is bumped; in production it would need a published version to disappear from the registry. The lookup result is cached per piece version in `pieceRefreshSupportCache`.
diff --git a/brain/knowledge/decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md b/brain/knowledge/decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md
new file mode 100644
index 000000000000..21210624a7e5
--- /dev/null
+++ b/brain/knowledge/decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md
@@ -0,0 +1,85 @@
+---
+icon: 🪙
+status: accepted
+---
+
+# AI model tiers are published to the CDN from a second console
+
+## Decision
+
+The chat model tiers — `id`, `label`, `modelId`, `nativeModelId`, `thinkingBudget` — and
+`defaultTierId` move out of the code and into `cdn.activepieces.com/ai/pricing.json`. A **new repo**, `activepieces/ap-analytics`, is where
+marketing edits them and presses Publish. Activepieces fetches that file (15-minute TTL), keeps the last
+good copy, and falls back to the constants shipped with the release. The existing billing console
+is not touched.
+
+There is **no database table for the tiers**. The CDN file is the source of truth. Every publish
+also writes `ai/pricing/history/-.json`, which gives history and one-click undo
+with no table.
+
+## Context
+
+Every tier was a constant in the monorepo, so moving Expert to a newer model meant a code change,
+a release and a self-host upgrade. Marketing needed to do it themselves. Two problems fell out of that:
+where the UI lives, and how the values travel.
+
+The billing console already holds license keys, Autumn customers and money. Its own rule is
+"Architecture A+: one codebase, separate Supabase project per sensitive module" — but that
+separates only the **database**. It does not stop a config feature from breaking a deploy that
+also serves billing, and it does not stop anyone (or any agent) editing files next to that code.
+
+## Why
+
+A second repo separates the code and the deploy, not just the data. Config work can move fast
+without touching the repo that holds the master billing key.
+
+Publishing to the CDN rather than serving a live console endpoint keeps **billing correctness off
+the console's uptime**. The fetch/cache/backoff/stale-fallback code already existed and was proven
+(`model-catalog.ts`), so the reader is a copy of a working thing rather than new machinery.
+Rejected: AP calling a console API on every read, which would make a config tool an availability
+dependency of charging customers.
+
+No table because the CDN object already gives durable storage, and archive copies give history for
+one extra upload call. Rejected: a versioned Postgres table, which is a migration, a schema and a
+second copy of the truth for no gain.
+
+The cost is propagation delay (about 20 minutes, from `max-age=300` plus a 15-minute in-process TTL) and
+that publishing needs CDN write credentials in the new repo. The current key can write anything in
+the bucket; a key scoped to `ai/pricing*` is the follow-up.
+
+## Consequences
+
+- **The pricing schema now lives in two repos** and they must match. `packages/shared/src/lib/ai-pricing/`
+ in ap-analytics, `packages/server/utils/src/model-tier-catalog.ts` here. Change one without the
+ other and Activepieces rejects the file and silently keeps serving the old tiers. The reader
+ ignores keys it does not declare, which is what kept the console publishable while it still sent
+ the credit fields; ap-analytics dropped those on 2026-09-20 and added `nativeModelId`, so the two
+ schemas name the same five tier fields again.
+- A tier change is no longer visible in `git log` of this repo. The audit trail is the CDN history
+ prefix.
+- The reader **fails closed**: bad schema, no tiers, or a missing default tier all fall back to the
+ last good file, then to the release's own `ACTIVEPIECES_CHAT_TIERS`. CE and self-hosted keep
+ working with zero setup.
+- Publishing is blocked whenever the live file cannot be read, so a CDN outage can never overwrite
+ tiers nobody can see.
+- **This started out as a price file and is not one any more.** It originally carried
+ `MANAGED_MODEL_WEIGHTS`, a per-tier `creditWeight` and `unpricedModelCreditWeight`. Main deleted
+ every weight table in [#15494](https://github.com/activepieces/activepieces/pull/15494) and now
+ bills managed AI on the dollar cost the provider reports per call, so there is no fixed price left
+ to publish. Only the tier table survived. The object is still called `ai/pricing.json` because it
+ is already live and the console writes there; the name is now wrong, and renaming it needs both
+ repos plus the live object to move together. The console page, its nav entry and its docs were
+ renamed to **AI Tiers** instead, because an editor that still showed a credits box let an admin
+ change a number, press Publish and believe they had changed billing.
+- **The two guards on publishing are not equally strict, which is easy to misread.** Reading the live
+ `pricing.json` before a write fails *closed* (above). The separate check that every priced model id
+ exists in `ai/model-catalog.json` fails *open*: when the catalog cannot be loaded,
+ `ai-pricing-service.ts` logs 'The model catalog is unavailable, so model ids were not checked before
+ publishing' and publishes anyway. So a typo in a tier's `modelId` can reach the CDN, and every chat
+ turn and agent run on that tier then asks the provider for a model that does not exist.
+- Config features from now on go in `ap-analytics`, not the billing console.
+- **The repo is named `ap-analytics` on GitHub, but calls itself "config console" inside** — README,
+ `CLAUDE.md` and the package names all say config-console. It was created as `config-console` and
+ renamed on 2026-09-02. A clone made before the rename pushes into a 404; fix it with
+ `git remote set-url origin git@github.com:activepieces/ap-analytics.git`.
+- **Internal tools run on DigitalOcean, but not all the same way.** `activepieces/discover` uses a **droplet** driven by Actions over SSH (`.github/workflows/deploy.yml` — build, rsync, `pm2 reload`), with nginx and certbot in front. `ap-analytics` uses **App Platform** instead: DO builds the Dockerfile, redeploys on push by itself, and issues TLS, so there is no server to log into and the repo carries no deploy workflow at all. Neither repo says which it is, so check the DO dashboard before assuming. Runbook: `DEPLOYMENT.md` in ap-analytics.
diff --git a/brain/knowledge/engineering/ci-pipeline.md b/brain/knowledge/engineering/ci-pipeline.md
index acdbd443c5aa..815370ca5d21 100644
--- a/brain/knowledge/engineering/ci-pipeline.md
+++ b/brain/knowledge/engineering/ci-pipeline.md
@@ -4,7 +4,7 @@ icon: ⏱️
# CI Pipeline
-The PR checks that decide whether a change builds and passes tests. Lives in `.github/workflows/ci-v2.yml` (`ci.yml` runs beside it until the side-by-side comparison ends). The gates that shape *how a PR is reviewed* are on *CI PR Review Hygiene*.
+The PR checks that decide whether a change builds and passes tests. Lives in `.github/workflows/ci.yml`; it replaced the original `ci.yml` on 2026-09-27 after running beside it as `ci-v2.yml`. The gates that shape *how a PR is reviewed* are on *CI PR Review Hygiene*.
**changes job** — the first job. Runs `turbo ls --affected` to list the packages the PR touches plus everything that depends on them, and sets `all=true` when a file outside any package changed (workflows, root config, `tools/`). Every other job keys off those two outputs.
@@ -23,14 +23,16 @@ The PR checks that decide whether a change builds and passes tests. Lives in `.g
- api's `test` script runs ce+ee+cloud serially. Any `turbo run test` over many packages must exclude api (`--filter='!api'`; `tools/scripts/test-filters.ts` does the same).
- The api e2e tests (`execute-flow-e2e`, `test-step-e2e`, `piece-options-e2e`) import the worker's source, and `worker.ts` imports `@activepieces/sandbox`, which resolves to `packages/server/sandbox/dist`. A job that builds only api fails those three files with "Failed to resolve entry for package @activepieces/sandbox". The api jobs build `worker` as well and also run when worker is affected.
- A cache saved during a pull request run is visible only to that PR and to nothing else. Caches every PR should hit (bun downloads, the compiled Redis binary) must be created on `main`; `warm-ci-cache.yml` does that on every push to main.
-- Two turbo invocations running at the same time in one job race on `cache: false` builds: the old `ci.yml` failed `api#build` with "@activepieces/shared has no exported member" for a member that exists, because a second invocation was rewriting `shared/dist` while api's tsc read it. One invocation per job.
+- Two turbo invocations running at the same time in one job race on `cache: false` builds: the retired pipeline failed `api#build` with "@activepieces/shared has no exported member" for a member that exists, because a second invocation was rewriting `shared/dist` while api's tsc read it. One invocation per job.
- `--affected` and `--filter` intersect in turbo; there is no union flag. To run "the affected packages plus this fixed set" in one invocation, pass the affected names (the `changes` job outputs them as JSON) as explicit `--filter=` arguments alongside the fixed ones. Two invocations rebuild the shared dependency chain twice because `build` is uncached.
- Pieces are the one place the pipeline overrides the dependency graph, by decision (2026-09-24). A piece runs when its own files changed; every piece runs when `pieces/framework` or `pieces/common` changed. A change to `core-piece-types`, `core-utils`, `core-formula` or `core-execution` does not pull the 700+ pieces in, although they depend on it, because that cost about 20 minutes per such PR (5 of the last 60). The accepted blind spot: a core type change that breaks piece compilation is caught when pieces are published (`release-pieces.yml`), not in the PR. The `changes` job computes this with two turbo listings, `ls --filter='[base...HEAD]'` (changed directly) and `ls --affected` (plus dependents).
+- The engine's tests execute five pieces (`AP_DEV_PIECES` in `packages/server/engine/vitest.config.ts`: http, data-mapper, approval, webhook, delay), so those five are engine devDependencies. That edge is what makes `--affected` pull engine in when one of them changes; without it a pieces-only PR ran the piece's checks and skipped the engine tests that exercise it (caught on the retirement PR, 2026-09-27). Three lists name the same set and must move together: that `AP_DEV_PIECES`, `@activepieces/engine#test` `dependsOn` in `turbo.json`, and engine's devDependencies.
- `TURBO_SCM_BASE` must be `origin/ ` in CI. turbo's default base is `main`, which a CI checkout does not have, so without it every run degrades to "everything affected".
+- Required status checks are set by a repo admin (branch ruleset on `main`) and match on job display name. A job its `if:` skips reports `skipped`, which GitHub counts as passing, so every job here can be required: `changes`, `lint`, `build-test`, `api (ce)`, `api (cloud)`, `api (ee, unit, migrations)`, `tool-search (postgres)`. A workflow-level skip (`on.paths`) leaves a required check pending forever, so keep the filtering inside the `changes` job. On 2026-09-27, when the old `ci.yml` was retired, nothing was required yet: `isRequired` was false on every check and the only ruleset enforced codeowner review.
## Key files
-- `.github/workflows/ci-v2.yml` — the PR pipeline
+- `.github/workflows/ci.yml` — the PR pipeline
- `.github/actions/setup` — shared install and cache steps
- `tools/scripts/test-filters.ts` — every package with a `test` script, minus api
- `tools/scripts/check-migration-rollback.ts` — new migrations must declare `breaking` and `down()`
diff --git a/brain/knowledge/engineering/ci-pr-review-hygiene.md b/brain/knowledge/engineering/ci-pr-review-hygiene.md
index 121f51ef3613..b3c72cb7ff69 100644
--- a/brain/knowledge/engineering/ci-pr-review-hygiene.md
+++ b/brain/knowledge/engineering/ci-pr-review-hygiene.md
@@ -63,13 +63,14 @@ Enforcement is the **`Codeowners review` repository ruleset** (active on the def
- **The same integration test can exist once per edition, so changing a shared service means grepping the assertion, not trusting the file you already edited.** `passwordless-authn.test.ts` lives under `test/integration/ce/authentication/` on main, and a branch may carry its own copy elsewhere — a behaviour change to `requestCode` or `signUp` has to update every copy. This bites hardest after rebuilding a branch onto a different base, which resurrects files the old base had moved: the edit list from the first attempt is then silently incomplete, and because api unit tests do not gate CI (below), the edition copy is the only thing that catches it. Grep the *assertion* (`DOMAIN_NOT_ALLOWED`, the fixture domain) across `test/` rather than the filename.
- **When a refusal and a success deliberately share a status code, a status-only assertion passes for the wrong reason.** The invited-member test kept asserting `204` and kept passing after the guard it covered stopped running at all. Any silent-failure design has to be pinned on side effects — rows created, mail sent, spies called — because the response is by construction indistinguishable.
- **In a vitest unit test, import the module under test statically — `vi.mock` is hoisted above imports.** The existing `worker-group.service.test.ts` reaches for `await import(...)` to load its subject after the mocks, which is unnecessary and, if you copy it to the *top level* of a file rather than inside a function, fails `tsc -p tsconfig.spec.json` with `TS1378: Top-level 'await' expressions are only allowed when the 'module' option is set to …`. Vitest itself runs it happily and lint says nothing, so the only thing that catches it is a typecheck nobody gates on. A plain `import { thing } from '…'` alongside the `vi.mock` calls works and typechecks.
-- **A unit test added under `packages/server/api/test/unit/` never runs in CI.** `ci.yml` runs exactly two test commands: `turbo run test` filtered to engine/shared/sandbox/ai-providers/pieces-framework/web, and `turbo run test-ce test-ee test-cloud check-migrations --filter=api`. The api package *has* a `test-unit` script (`vitest run test/unit`), but no workflow invokes it and the root `test-unit` filter list does not include api — so the 10+ files already sitting in `test/unit/**` are dead weight, and a new one passes review while protecting nothing. `packages/core/execution` is in the same position. Until the wiring changes, put api coverage that must actually gate merges in `test/integration/ce|ee|cloud`, and if you do add a unit test, say in the PR that you ran it locally and paste the result.
+- **`packages/server/api/test/unit/` runs in CI since 2026-09: the `api (ee, unit, migrations)` job runs `test-unit` whenever api or worker is affected.** Before the affected-package pipeline no workflow invoked api's `test-unit` script, so the files there rotted unseen. The root `npm run test-unit` still skips api; run `npx turbo run test-unit --filter=api` locally instead.
- **`tools/scripts/` is outside the lint and test wiring.** ESLint ignores it, and `npm run test-unit` only covers engine/shared/web. A script there with real policy logic must run its own tests from its own workflow — `pr-size.yml` runs `bun test tools/scripts/pr-size-check.test.ts` as a step before the check itself.
- **The api package's `lint` script is `eslint 'src/**/*.ts'`, so nothing under `packages/server/api/test/` is ever linted.** `npx turbo run lint --filter=api` reports 0 errors on a tree whose test files carry real `import-x/order` **errors** — `test/integration/ce/mcp/mcp-activity-recording.test.ts` has had three since it was written. Point ESLint at the test paths yourself when you touch them (`npx eslint test/integration/ce/ `), because neither CI nor `npm run lint-dev` will. Related: a lint run over a whole `src/app/ ` tree OOMs at node's default heap in this repo — `NODE_OPTIONS=--max-old-space-size=8192` gets it through.
- **Reopening a bot-closed external PR is futile until a core member adds `keep-open` first.** `close-external-prs.yml` triggers on `pull_request_target` `[opened, reopened]`, so every reopen re-runs the same comment-then-close step; its `if` exempts OWNER/MEMBER/COLLABORATOR, bots, and the `keep-open` label, and nothing else. A docs PR from an outside contributor ([#15031](https://github.com/activepieces/activepieces/pull/15031)) was reopened 13 times over two days and closed 13 times within seconds of each, until a member labelled it `keep-open` and reopened it once. The same job also runs a nightly `actions/stale` pass that closes any PR idle 60 days. The lasting fix for a change worth keeping is to re-open it from a branch owned by someone with write access — author association, not the diff, is what the gate reads.
- **`license/cla` keys off the commit author email, so re-opening someone else's branch under your own name does not clear it.** CLA-assistant walks every commit in the PR rather than the PR author, and an author email that matches no GitHub account can never be matched to a signature — the 47 commits carried over onto [#15092](https://github.com/activepieces/activepieces/pull/15092) were authored as `ashrafsam@mac.lan`, a local hostname, so the check sat at `not_signed` on a PR opened by a member. It is not in the `main` ruleset's required-checks list, but it is red on the page and a reviewer reads that as unmergeable. Either the original author signs through the PR link, or the commits get re-authored to an email tied to their GitHub account before you open it.
- **A branch that predates the `brain/` → `brain/knowledge/` move cannot edit a brain page in place — GitHub will call the PR conflicting even when `git merge` is clean locally.** Git follows the rename and merges the modification into the new path; GitHub's mergeability check does not, so it reports `modify/delete` on the old path and the PR goes `dirty`. Local `git merge-tree --write-tree` exits 0 and hides the problem; reproduce what GitHub sees with `git merge -X no-renames origin/main`. Fix: merge `origin/main` into the branch first, which lands the edit at the new path, then push.
-- **`breaking-change-check` couples the docs entry to the label in BOTH directions, so back-documenting an already-shipped change drags the label onto a docs-only PR.** R3 in `tools/scripts/breaking-change-check.ts` fails a PR that adds a `####` entry to `docs/install/reference/breaking-changes.mdx` without `⛓️💥 breaking-change`, exactly as it fails the label without an entry — and the template answer has to agree too, so "yes" must be ticked on a PR that changes no code. It reads the *added lines of that one file* from `git diff origin/ ...HEAD`, and `hasBreakingEntry` wants a `####` heading **plus** a non-heading body line, so a heading alone, a `---`, or a version bump does not count. Two consequences: the label then collides with `skip-changelog` in release-drafter (pick one deliberately — the feature's own PR usually already carried the changelog entry), and an entry appended to a *released* section still trips it, since the check never looks at which heading the lines landed under.
+- **Merging `main` into your branch makes Greptile review `main`'s shipped code as yours, and the score drops for files you never touched.** It appears to review the commits in the PR's range rather than the diff against the base, so a merge commit drags the commits it brought in along with it — [#15550](https://github.com/activepieces/activepieces/pull/15550) went 4/5 → 3/5 on a finding in `agent-conversation-service.ts` and `agent-run-controller.ts`, both arriving from #15548 in a merge done minutes earlier, neither in the PR's own diff. The check is one command: `git diff --name-only origin/main HEAD` — if a "Files Needing Attention" entry is not in that list, it is not this PR's. (Three-dot `origin/main...HEAD` works too; after merging `main` the merge base *is* `main`, so the two agree.) Do not stop at "not mine", though: that finding was real, a lookup by id with no `projectId` filter, and it is live on `main` where nobody is reviewing it any more — report it against the owning PR rather than letting the misattribution bury it. Rebasing instead of merging avoids the whole effect, but this repo merges, so expect it after every catch-up.
+- **`breaking-change-check` couples the docs entry to the label in BOTH directions, so back-documenting an already-shipped change drags the label onto a docs-only PR.** R3 in `tools/scripts/breaking-change-check.ts` fails a PR that adds a `####` entry to `docs/install/reference/breaking-changes.mdx` without `⛓️💥 breaking-change`, exactly as it fails the label without an entry — and the template answer has to agree too, so "yes" must be ticked on a PR that changes no code. It reads the *added lines of that one file* from `git diff origin/ ...HEAD`, and `hasBreakingEntry` wants a `####` heading **plus** a non-heading body line, so a heading alone, a `---`, or a version bump does not count. Two consequences: the label then collides with `skip-changelog` in release-drafter (pick one deliberately — the feature's own PR usually already carried the changelog entry), and an entry appended to a *released* section still trips it, since the check never looks at which heading the lines landed under. **When the check fails, removing the label is sometimes the right fix, not writing an entry.** A long-lived branch can stop being breaking underneath you — merging `main` can delete the very thing that was breaking, and then the label and the template answer are both stale while the code is fine. Re-read what the branch still does against the template's own list before reaching for `breaking-changes.mdx`. Handy either way: the job re-runs on a **label change**, so flipping the label clears it with no empty commit and no push.
- **A release cut empties `## Unreleased` into a version section, so a long-lived branch's entry conflicts in a way that is wrong to resolve by hand.** The entries do not move to the bottom of the file — the whole Unreleased block is relabelled as the new version and a fresh Unreleased is opened above it. A branch that added its entry weeks ago then conflicts against a section that has been relocated wholesale, and git presents it as "your entire block versus one stray line", which invites resolving the hunk in place. Doing that leaves the entry sitting inside an already-released section, where it is both wrong for readers and invisible to `breaking-change-check` (see the bullet above: the check never looks at which heading the added lines landed under, so CI stays green). Take main's copy of the file whole and re-insert the entry at the end of the *current* Unreleased section instead, then check that the entries you unioned on an earlier merge each appear exactly once — the previous resolution's placement is usually now a duplicate. Seen on GIT-1764 / #14899, which conflicted twice in one day for this reason.
- **Nothing rolls `## Unreleased` over at release time, and the docs site is unversioned — so a breaking-changes entry has to name its own version.** No workflow or script writes to `docs/install/reference/breaking-changes.mdx` (`breaking-change-check.ts` only reads it), and `git log -S"## 0.88"` on the file comes back empty: the heading has not moved since 0.87.0, so entries for work that shipped months ago still sit under "Unreleased" (PM2 removal in 0.88.2, cache pre-warm gate and workspace naming in 0.89.0, …). `docs/docs.json` has no versioning either, so there is one live page for every self-hoster whatever version they run, published on merge rather than on release — the version heading is the *only* thing telling a reader whether a change is already in their build. So before adding an entry, run `git tag --contains ` on the change it describes and file it under the release that actually shipped it; only genuinely unshipped work belongs under "Unreleased". What points self-hosters at the page in the first place is `release-drafter.yml`, which appends a "review the Breaking Changes page" line to every release body and groups `⛓️💥 breaking-change` PRs under their own heading — which also means a docs-only PR back-documenting an old change shows up in the *next* release's breaking-change list.
- **Greptile enforces the file-order rule on *private* constants too, which CLAUDE.md only states for exported ones.** CLAUDE.md says "Exported types and constants must be placed at the end of the file" and gives the order as imports → exports → helpers → types; Greptile reads that as covering module-private constants as well, and flags a `const` sitting above the file's exported symbol (P2 on [#15226](https://github.com/activepieces/activepieces/pull/15226), for two constants only read inside the service they sat above). It has that as a stored custom-context memory, so it will keep raising it. Put private constants in the helpers section below the export — hoisting is a non-issue when they are only read at call time.
diff --git a/brain/knowledge/engineering/engineering-handbook-playbooks.md b/brain/knowledge/engineering/engineering-handbook-playbooks.md
index 649ee54a6301..e6add8a47e75 100644
--- a/brain/knowledge/engineering/engineering-handbook-playbooks.md
+++ b/brain/knowledge/engineering/engineering-handbook-playbooks.md
@@ -21,6 +21,7 @@ Run EE, building for self-hosting, setup BetterStack, releases, canary deploymen
Postmortems (Redis/queue overload, infra upgrade — March 2026) and product interface-design notes.
## Gotchas
+- **Put a scratch worktree *inside* the repo root, or it cannot lint or typecheck at all.** Node resolves `node_modules` by walking up the directory tree, so a worktree at `/tmp/...` finds nothing and eslint dies on `couldn't find the plugin "@typescript-eslint/eslint-plugin"` before it reads a single rule — no install, no symlink, no checks. The same worktree created at `.claude/worktrees/` walks up to the repo root's `node_modules` and runs lint, `tsc` and vitest with zero setup. This is cheaper than both workarounds in the next bullet and has none of the cross-branch skew, because the resolved packages belong to the same checkout. It matters most during a long conflict resolution: files you edit while resolving are easy to treat as "already reviewed" and never check again, and in an unusable worktree you *cannot* check them. A merge resolved in `/tmp` shipped a `sort-imports` error and a `TS2783` to CI this way — both in files touched only during conflict resolution, both caught by the first `turbo run lint` / `turbo run typecheck` that could actually run. After a merge, run the task CI runs (`turbo run lint --filter=`), not a list of the files you remember editing.
- **Running EE locally (outside the dev container) to preview a chat/agents-gated view hits four macOS-specific walls the [Run EE](https://www.activepieces.com/docs/handbook/engineering/playbooks/run-ee) playbook doesn't cover.** (1) The default dev DB, PGlite (`.env.dev`'s `AP_DB_TYPE=PGLITE`), hard-refuses `AP_EDITION=ee`/`cloud` with `Error: Edition ee not supported in pglite mode in dev environment` — point at a real local Postgres instead (`AP_DB_TYPE=POSTGRES` + a throwaway DB via `createdb`). (2) In a fresh git worktree, symlinking `node_modules` from another worktree/checkout works for lint/typecheck but not for actually running the server: workspace packages resolve through the symlink to whatever commit *that* checkout is on, so a genuine cross-branch source/type skew appears (e.g. `AIProviderName.VERTEX does not exist`) purely from the mismatch — do a real `bun install` scoped to the worktree instead. (3) `bun install` aborts the *entire* install (not just its own package) if `isolated-vm`'s native build fails, which it does on Darwin 25 (`node-gyp` / `undici` `webidl.util.markAsUncloneable is not a function`) — even though `isolated-vm` is only needed by the engine/worker sandbox, not by `api`+`web`. Fix: `bun install --ignore-scripts`, then `cd` into `node_modules/.bun/sqlite3@*/node_modules/sqlite3` and run `npx prebuild-install -r napi` directly to fetch its prebuilt native binding (something in the queue/api dependency tree requires it to *load* even when the configured DB isn't sqlite). (4) The repo requires Node 22.15+/24 (`bootstrap.ts` uses `zlib.zstdDecompress`, added in Node 22.15); a system default of Node 20 (common via `nvm`) crashes API boot with `ERR_INVALID_ARG_TYPE` in `file-compressor.ts` — put a `nvm`-installed v24 first on `PATH` before starting `serve`. Once booted: `AP_EDITION=ee` alone (no license key) is enough to preview chat/agents, since the unlicensed `AUTUMN_FREE_PLAN` already sets `chatEnabled`/`agentsEnabled: true` — but that plan's `aiProvidersEnabled: false` hides the "Connect" button on every provider card, so there is no UI path to satisfy chat's "Set up an AI provider" gate; insert a throwaway row into `ai_provider` directly via SQL (`enabledForChat: true`, `config: '{}'`, any `auth` JSON — it's only decrypted when a message is actually sent) to get past it for pure layout/UI verification.
- **`DISCORD_ON_CALL_WEBHOOK` is the single repo secret behind every on-call Discord notification** — cloud production deploys (`continuous-delivery-cloud.yml`), self-hosted releases (`release-self-hosted.yml`), and release-pieces failure alerts all post through it. Rotating it repoints all of them at once; there is no per-workflow webhook. Self-hosted release notifications skip `-rc` tags unless `publish_rc_release` is set, mirroring the release-drafter condition so the message never links a release page that doesn't exist.
- **Docs media lives on the CDN, never in the repo — and videos and images sit on two different paths.** A video embeds as ` `, an image as ``. Videos are namespaced under `videos/docs/`; images are **flat** under `assets/` with no `docs/` segment, so don't infer one path from the other — check the URL you actually mean. Both mirror the filename verbatim, so an upload is fetchable with no config change. Committing the binary instead bloats every clone forever (`docs/videos/` reached 63 MB and `docs/images/` 1.6 MB before both were removed in Sept 2026) since git history keeps the blob even after a delete. Curl each URL for a 200 before deleting a local copy: `docs/` also carries stale `.mdx` references to images that never existed in git history at all, and rewriting one of those to a CDN URL turns a visibly-missing asset into a confident 404.
diff --git a/brain/knowledge/engineering/server-module-anatomy.md b/brain/knowledge/engineering/server-module-anatomy.md
index 89f4672a17c6..558bb3f8a6b0 100644
--- a/brain/knowledge/engineering/server-module-anatomy.md
+++ b/brain/knowledge/engineering/server-module-anatomy.md
@@ -129,7 +129,7 @@ Queued work: add to `SystemJobName` or `WorkerJobType` in shared, register the h
Verify with `npm run lint-dev` and `npm run test-api`.
-**`packages/server/api/test/unit/**` runs in no pipeline — do not trust it as a safety net.** The package defines a `test-unit` script, but CI only runs `turbo run test-ce test-ee test-cloud check-migrations --filter=api` (`ci.yml`), and the root `npm run test-unit` filters to `engine`/`shared`/`sandbox`/`core-utils`/`server-utils`/`pieces-framework`/`web`/`ee-embed-sdk` — `api` is not in that list. So those specs are only ever run by hand, and they rot: measured Aug 2026 on a clean `main`, **18 tests across 4 files already failed** (`workers/job-queue/job-broker`, `workers/machine/machine-service`, `core/canary/worker-group.service`, `knowledge-base/file-service-delete`). Two consequences: put a server test you actually want enforced under `test/integration/ce`, and when a local `test/unit` run goes red, check `main` before assuming your branch caused it.
+**`packages/server/api/test/unit/**` runs in CI since 2026-09 (the `api (ee, unit, migrations)` job, whenever api or worker is affected).** It ran in no pipeline before that and rotted: in Aug 2026, 18 tests across 4 files failed on a clean `main`. The root `npm run test-unit` still skips api; run `npx turbo run test-unit --filter=api`. When a `test/unit` run goes red locally, check `main` before assuming your branch caused it.
## Gotchas
- **A date-range query filter typed `z.string()` turns bad input into a 500, not a 400.** `createdAfter`/`createdBefore` bind straight into a `created >= :createdAfter` comparison against a `timestamp with time zone` column, so `?createdAfter=invalid` reaches PostgreSQL and surfaces as `invalid input syntax for type timestamp with time zone: "invalid"` — an internal error for ordinary malformed input. Type them `z.iso.datetime({ offset: true }).optional()` instead: that accepts everything a caller actually sends (`.toISOString()` and `+02:00` offsets alike) and rejects the garbage at the edge, where `fastify-type-provider-zod` already turns a querystring rejection into a 400. Note `z.string().datetime()` is deprecated in zod v4 — `z.iso.datetime()` is the replacement, and its default is Z-only, so omitting `{ offset: true }` also rejects legitimate offset timestamps. **The loose shape is still on `flow-run`, `audit-events` and health metrics**, so copying a date filter from any of them carries the hole along.
@@ -160,6 +160,8 @@ Verify with `npm run lint-dev` and `npm run test-api`.
- **Reverting a merged migration PR needs a hand-resolved conflict, and it does not unrun the migration.** GitHub's revert button fails ("couldn't be reverted automatically") as soon as another migration has been registered since yours: both the import block and the `getMigrations()` array put the new entry on the line next to the old one, so both hunks conflict. This is the one case where the usual union resolution is wrong — drop yours, keep the later one. The slot then becomes a hole in the sequence, which TypeORM does not mind, and the number stays safely reusable because migrations are matched by class name. The data is the real question: TypeORM records the migration as run and never revisits it, so the revert leaves every database that already executed `up()` in the new state, and deleting the class removes the only `down()`, putting it out of reach of `rollback-migrations.ts` too — copy that SQL out before you delete the file. To find out who has run it: `git tag --contains ` empty means no release has it, `git merge-base --is-ancestor release-candidate` failing means cloud production has not, and staging has it either way, because `continuous-delivery-stg.yml` deploys every push to `main` (GIT-1778 / #14982, reverted 2026-09-16).
- **A new `AppSystemProp` needs three edits, not one.** Add the enum entry in `system-props.ts`, a default in `systemPropDefaultValues` (`system.ts`), *and* a validator in `systemPropValidators` (`system-validator.ts`). Miss the validator and `validateEnvPropsOnStartup` throws `systemPropValidators[prop] is not a function` at boot — every API test fails on setup, not just the new one. Document the var in `docs/install/reference/environment-variables.mdx` too.
- **`permission: undefined` on `securityAccess.project(...)` silently allows any project member.** The argument is required in practice even though the type tolerates omitting it.
+- **Anything that does network I/O inside a module's `setup()` adds its own timeout to boot.** `app.ts` awaits every `setup()` and every `register()` in one sequential chain before the server listens, so a single `await someClient.load()` in a `setup()` means a slow or unreachable host delays startup by that client's full timeout — on every boot, for every self-hoster, including the ones with no outbound internet who will never get a useful answer. An unbounded `await` of the model-tier warm-up in `aiProviderService.setup()` put up to 10 seconds on the boot path this way; `modelTierCatalog.warmUp()` now races a 2 s timer for exactly that reason. The repo already has the right idiom a few lines above, and it is worth copying rather than reinventing: `rejectedPromiseHandler(toolSearchReindexJob(app.log).backfillIfNeeded(), app.log)` fires the work off and lets boot continue, with a comment saying it must never block or fail boot. If you genuinely need the value before the first request, give that one call its own short timeout instead of inheriting the client's.
+- **`security` goes under `config`, never under `schema`, and putting it in the wrong half fails silently.** Fastify reads the security config off `config`; a `security` key inside `schema` is just an unknown property on the schema object, so nothing validates it, nothing warns, and the route ships with no security config at all. It reads as correct at a glance because the two keys sit next to each other in the same literal and every route in the file has both. Caught on `GET /v1/ai-providers/tiers` before merge only because the diff was being read line by line. When you add a route, compare its literal against the one above it: `config: { security }` then `schema: { body, response }`, in that order.
- **Every query filters by `projectId` or `platformId`.** For connections with multi-project access, use `ArrayContains([projectId])` on the `projectIds` array column.
- **A mutation test against a `packages/core/*` package proves nothing until you rebuild its dist.** The api package resolves `@activepieces/core-utils` and friends through `node_modules` to `dist/`, not through the tsconfig path to `src/`, so breaking the source and re-running an api test reports a pass while the test is still executing the old build. Measured: removing the connection-template unwrap from `core-utils/src` left `agent-tool-pinning.test.ts` fully green, and the same mutation failed it once `turbo run build --filter=@activepieces/core-utils` had run. Web tests do not share the trap — vitest aliases the core packages to `src` in `packages/web/vitest.config.ts` — so a web suite catching a core mutation while the api suite ignores it is the signature of a stale dist rather than of missing coverage. Same cause as the phantom "has no exported member" errors after merging a branch that adds a core export.
- **`npx vitest` must be run from `packages/server/api`, never the repo root.** The config's `setupFiles` is what loads `.env.tests`, and from the root that setup never runs, so every suite dies in `beforeAll` with `SYSTEM_PROP_NOT_DEFINED: AP_POSTGRES_DATABASE` (thrown from `createPostgresDataSource`) and vitest reports the file as a failed *suite* with all its tests skipped. It reads exactly like a real breakage in whatever you just changed — the tell is that the stack bottoms out in `test-setup.ts`/`database-connection.ts` and never reaches your code, and that the run's stdout is missing the `Configuring vitest …/.env.tests` line a healthy run prints first. `npm run test-api` sets the cwd for you; a hand-written `npx vitest run test/…` does not.
diff --git a/brain/knowledge/flows-execution/chat.md b/brain/knowledge/flows-execution/chat.md
index fcea9e1f4882..6058ac3011d4 100644
--- a/brain/knowledge/flows-execution/chat.md
+++ b/brain/knowledge/flows-execution/chat.md
@@ -39,6 +39,7 @@ A turn is kept alive / reclaimed by three separate mechanisms in `execute-agent-
- **The context budget ignores tool schemas and reserved `max_tokens`.** Anthropic/OpenRouter count both against the 200k window; `agent-compaction.ts` budgets neither. It trims history to `COMPACTION_THRESHOLD (0.7) × 200_000 = 140_000` and its fit check looks only at message chars, while `run-agent-turn.ts` sets `maxOutputTokens: tier.thinkingBudget + 32_000` → 52k reserved on premium, plus ~12k of tool schemas (62 tools, 41 via MCP). 140k + 12k + 52k = 204k, so a conversation that compacts to just under the threshold still 400s with "maximum context length is 200000 tokens" — and it gets retried ~6× (`streamText maxRetries: 3` × `MAX_STREAM_RETRIES`), burning ~20s per turn. `maxOutputTokens` is set at the `streamText` call level, so the full thinking budget stays reserved even on step one where `prepareStep` disables thinking and swaps in haiku-4.5 (real case: 148_628 text + 11_872 tool + 52_000 output = 212_500; dropping the unused 20k reservation alone would have fit). `ESTIMATED_TOKENS_PER_MESSAGE = 200` also sizes the recent window by message *count*, so a 12-message history holding ~235k tokens of uploaded documents summarized only 1 message. When budgeting, subtract the reserved output window and tool-schema size from `getMaxContextTokens`, and don't reserve `thinkingBudget` on a thinking-disabled step.
- **A write tool in `BUILD_ONLY_TOOL_NAMES` is only reachable if something flips the phase for it.** The denylist is the consistent home for anything that writes (`ap_create_flow`, `ap_create_table`, `ap_lock_and_publish` are all in it), but the only route out of `discovery` is `ap_set_phase`, whose description tells the model to switch when it starts *building an automation*. A tool for a subject with no build guide and no sibling build-only call, the agent-building tools being the case that found this, becomes invisible in any conversation that never builds a flow: `activeToolsForPhase` filters it out and the prompt names no tool, so the model cannot discover that it exists. Classifying by "does it write" is not enough; check what would actually flip the phase in a conversation about *that* subject. The four agent *write* tools (`ap_create_agent`, `ap_update_agent`, `ap_add_agent_tool`, `ap_remove_agent_tool`) were added to the set and then reverted for exactly this, with a test pinning the choice; `ap_list_agents` is a read and was never in it, so the group is five tools and only four were ever candidates.
- **Capability notes are built where `discoveryOnly` is not known, so a prompt can promise tools the worker has stripped.** `getAgentConfig` composes the system prompt in the api, while `discoveryOnly` rides on the job data; before Aug 2026 it never crossed that boundary. Meanwhile the worker strips image tools, email tools and the agent tools on such a run, so the notes claimed all three. Two of the three had been wrong since long before anyone noticed, because each note computed its own availability term. The flag now travels with the config request and the three notes read one shared `actingRun = !dryRun && !discoveryOnly`. Whenever a tool group is gated on a run mode in the worker, the note that advertises it has to be gated on the same term, in one place.
+- **Chat files belong to a conversation, not just a project.** Conversations are private to their user, so a project check is not enough. `saveAgentFile` and `persistAgentAttachments` stamp `conversationId` into the file metadata, and `readAgentFile` refuses a file whose stamp doesn't match. Files saved before the stamp existed can't be read back (image editing can't use them). Any new tool that reads chat files by a model-supplied ID must go through `readAgentFile`.
- Local dev needs `AP_DB_TYPE=POSTGRES` + Redis; refuses PGLite. **Prefer `AP_EDITION=cloud` over `ee` for chat work.** Cloud boots locally against plain Postgres and Redis with no Autumn, Stripe or license-key config (verified Aug 2026: API healthy, migrations applied, zero billing or license errors), and on Cloud `chatVisibility` returns `planChatEnabled || cloudRolloutOpen || userHasChatted`, so chat is simply **on** while the rollout cap is unfilled. On `ee` it is gated behind `plan.chatEnabled` and you have to get a plan onto the platform first. Note SMTP is usually unset locally, which makes the auth card open on the password form rather than the email-code step. Debug a run with `npm run chat:logs -- [runId]` (needs `LOG_FILE=true`/`AP_LOG_FILE=true` set when the turn ran — otherwise `.evlog/logs` is empty).
- **Chat was renamed to agent in code and DB, but only the storage half.** As of release 0.87.1 (`1823000000000-AddRenamedChatTableCompatViews`) `chat_conversation` → `agent_conversation` and `user_chat_memory` → `user_memory`, the server module moved `ee/chat/` → `ee/agent/` (entry point `agentModule`), the worker dir moved `jobs/ee/chat/` → `jobs/ee/agent/`, shared types moved `core/shared/.../ee/chat/` → `.../ee/agent/`, and `server/utils/src/chat-ai-utils.ts` → `agent-ai-utils.ts`. **The rename is not uniform, and the split is the thing to learn**: files describing chat as a *user-facing surface* deliberately kept their `chat-` names inside `ee/agent/` — `chat-visibility.ts`, `chat-rollout-service.ts`, `chat-rollout-user-entity.ts`, `chat-analytics-sync.ts`, `chat-tool-billing.ts`, `chat-usage-tracker.ts`, `chat-plan-grant.ts`. So a new chat-surface concern keeps the `chat-` prefix; a new stored entity takes `agent_`. The migration also leaves `CREATE OR REPLACE VIEW` compat views at both old table names, so raw SQL against `chat_conversation` still reads fine and will NOT tell you the rename happened — grep the entity, not the database.
- **An AI SDK major bump can typecheck clean while a callback payload silently changed shape.** v7 keeps most v6 option *names* as working deprecated aliases (`system`, `onStepFinish`, `experimental_repairToolCall`, `stepCountIs`, `result.toUIMessageStream`), so the option compiles but the data underneath can differ: `experimental_onToolCallFinish` survived as an alias for `onToolExecutionEnd` while its event lost `durationMs`/`success`/`error` (now `toolExecutionMs` plus a `toolOutput.type === 'tool-result'` discriminator). A type-probe that only names the option passes; you have to exercise each callback's property access. `onStepEnd`'s `content` is also cast to a structural `ContentPartLike` with an `args ?? input` fallback (`agent-ai-utils.ts`), which means a shape change there fails at **runtime**, not compile time — always smoke a real turn after a provider/SDK major.
diff --git a/brain/knowledge/flows-execution/flows.md b/brain/knowledge/flows-execution/flows.md
index 22020f07470e..42f41642aba3 100644
--- a/brain/knowledge/flows-execution/flows.md
+++ b/brain/knowledge/flows-execution/flows.md
@@ -32,6 +32,7 @@ Flows are the core automation primitive: a versioned directed graph of trigger +
- List filtering: `folderId` uses the string sentinel `"NULL"` for uncategorized; `folderIds` (array) loads all foldered flows in one request.
- Builder is Zustand-sliced (flow/run/canvas/step-form/piece-selector state). Canvas supports vertical (default) and horizontal orientations, and PNG export via a hand-rolled clone-and-rasterize pipeline.
- **`Popover.Trigger asChild` always composes its own open-toggle onto the child's click, even when `open`/`onOpenChange` is externally controlled — a `preventDefault()`-shaped guard is the only thing that stops it.** Radix wires `onClick={composeEventHandlers(props.onClick, context.onOpenToggle)}` on the trigger, and `composeEventHandlers` skips the second handler only if `event.defaultPrevented`. `ApStepCanvasNode` wraps *every* step (trigger and action) in `` (`flow-canvas/nodes/step-node/index.tsx`), but that prop only guards the app's own `onClick` — it was `handleStepClick`'s incidental `e.preventDefault()` that actually suppressed Radix's toggle for ordinary steps. PR #14405 ("close trigger piece selector on outside/repeat click") removed that `preventDefault()` and made `pieces-selector/index.tsx`'s `onOpenChange` unconditionally honor `open` for any step id — fixing a real empty-trigger close/repeat-click race, but with nothing scoping the new open-branch to the empty trigger specifically. Regression window: 2026-07-28 (#14405) to 2026-08-11 — any plain click on a *configured* step's body reopened its own piece selector, not just the empty trigger (case 1) or an explicit Replace (case 2, which sets the state directly from the context menu and never touches this trigger). Fix: gate the `onOpenChange` open-branch on `isForEmptyTrigger || openSelectorOnClick` instead of honoring every toggle — Radix's own attempt to open is simply ignored (state doesn't change, so the controlled `open` prop stays false) when neither condition holds. Confirmed both the break and the fix by reproducing the exact `Popover.Root`/`Trigger asChild` wiring against the real installed `radix-ui` package in a throwaway vitest — not just static reading. The general lesson (Radix always attempts the toggle; controlled mode only means *you* decide whether to honor it) applies to every controlled Radix `Trigger` in this codebase, not just this one.
+- **Rolling a release back past a `LATEST_FLOW_SCHEMA_VERSION` bump fails silently, not loudly.** `flowMigrations.apply` matches a row on exact equality (`flowVersion.schemaVersion === migration.targetSchemaVersion`), so a row stamped `'27'` running on code that only knows up to `'26'` matches no migration, is returned unchanged, and is not even rewritten — no error, no page, nothing in the log. The row survives; what does not survive is whatever the new migration wrote into it, because the old code has no idea how to read it. So a release that bumps the schema version is a one-way door for every flow read while it was live, and the only way back is `backupFiles[]`, which nothing reads on its own (`flowVersionBackupService.get()` has no callers). Ship a schema-version bump in a release of its own, with nothing in it you might want to revert for another reason. **And when the migration depends on a server-side change, put the two in different releases, server code first.** A rollback then lands on a server that still understands what the migration wrote: ship both together and undoing that release leaves every rewritten row unreadable, ship the migration one release later and the same rollback is safe.
- **Anything drawn in canvas coordinates must measure the canvas element, never infer its offset from app chrome.** The note and step drag previews are `position: absolute` inside the canvas and used to subtract `#project-sidebar`'s width plus a hardcoded 60px header; when the chat-first rail (#15005) replaced that sidebar the id vanished, the width read 0, and Add note silently stopped working — the preview drew a sidebar-width right of the cursor, so the placing click always missed it. Take the origin from xyflow's `useStore((s) => s.domNode)?.getBoundingClientRect()`. `position: fixed` is no way out: an ancestor carries `will-change: transform`, which makes it the containing block.
- **A stray DRAFT row makes a published flow look blank.** "Latest version" is resolved by `getFlowVersionOrThrow({ versionId: undefined })`, which is just `ORDER BY created DESC` with no state filter — so any DRAFT created after the LOCKED version *is* what the builder opens. This is why a half-created draft is a user-visible outage, not DB litter: recovering needs the row deleted or a `USE_AS_DRAFT`. Editing a published flow used to commit the empty DRAFT and import the locked content into it as two separate writes, so an import failure (field case: `RangeError: Maximum call stack size exceeded` on deeply nested flows — the recursive `sanitizeObjectForPostgresql` before the save is the prime suspect) left the flow rendering empty forever. GIT-1590 / Pylon #5225; the `RangeError` itself is still open as GIT-1593.
- **Draft creation is now atomic, in two different ways — know which one you're in.** `createNewDraftIfVersionIsPublished` runs `createEmptyVersion` + the IMPORT_FLOW loop in one `transaction()`, threading the `entityManager` down through `applyOperation` to the `updateLastModified` side effect. The *user's* operation deliberately stays outside that transaction (it would hold Postgres open across `prepareRequest` piece-metadata fetches and non-rollbackable file/webhook side effects) and is instead undone by a compensating `delete` of the freshly created draft. `flowService.create` wraps the flow row + first empty version the same way, so a failure can't leave a zero-version, unopenable flow.
diff --git a/brain/knowledge/memory.md b/brain/knowledge/memory.md
index 62309b2f627e..fd0352960015 100644
--- a/brain/knowledge/memory.md
+++ b/brain/knowledge/memory.md
@@ -1,4 +1,6 @@
- 2026-09-24 — `test-unit` (api unit tests) only joined the PR pipeline on 2026-09-20 (#15653). A branch whose last CI run predates that has never had its unit tests run in CI; expect first-time failures when it merges `main`, as #15550 did (a test mock disagreeing with the real code, not a merge problem).
+- 2026-09-20 — Found while planning the AI tier work: managed AI piece steps under-billed for about 23 days, between #14942 (2026-08-24) and #15494 (2026-09-16). #14942 changed the AI piece's `provider` prop to store an object (`{ provider, configId }`), but `flow-run-ai-usage-extractor` read that field with a string-only helper, so it resolved to `'unknown'`, failed the `provider !== ACTIVEPIECES` check in `resolveAiCreditWeight`, and charged **1 credit** per step instead of the model's table weight. It hit the five non-agent AI steps only, and only those configured or re-saved inside that window — a step saved earlier still held a plain string and billed correctly. `run_agent` was never affected, because it stores `aiProviderModel` instead. Nothing logged an error, and every test in the file passed a string provider, so the suite was green the whole time. The code is gone (#15494 deleted the extractor and bills on real provider cost now), so there is nothing left to fix — but the cloud credit events for that window are still wrong, and nobody has reconciled them.
+- 2026-09-20 — The live `ai/pricing.json` (version 1, published 2026-09-14) carried a leftover test tier `tier-4` / "New tier" on `anthropic/claude-opus-4.8`, created by clicking "add tier" in the pricing console and publishing. It was removed on 2026-09-24 by deleting the whole object, so the key is 404 until someone publishes again from the console; every server falls back to the bundled tiers and warns every five minutes until then. Next time, edit the row and publish; do not delete the object.
- 2026-09-06 — Follow-up (Option B, "stub-writer cascade"): fix `engine-run-callback-service.ts` `ensureLogsFileExists` (line 84-86) so a fresh log-file stub isn't written with `{ steps: {}, tags: [] }` when the underlying file is missing — that stub is what the next BullMQ retry downloads and trips `EmptyResumeStateError` on. Options: (a) store `internalError` on `flow_run` itself so no stub is needed; (b) use a distinguishable stub shape (`executionState: null`) that the engine reader rejects fast; (c) `UnrecoverableError` in worker on state-loss errors so BullMQ doesn't retry the stub. Handles manual-admin-ops case (case #5 in the retention-hunt discussion) and any non-retention internal-error that would otherwise loop. Ping user once C.2/W.1/#2/#4 have landed.
- 2026-09-10 — `bun.lock` on main is out of sync with four workspaces (three piece version bumps, plus `packages/pieces/community/sage-accounting` missing entirely), so `bun install --frozen-lockfile` — and therefore the Dockerfile — fails on main until someone runs `bun install` and commits the lockfile.
- 2026-09-10 — Measured embed analytics cost inside the embedded builder (headless Chromium, M-series Mac, 12-step flow, scripted zoom/pan/4 step-panel opens over ~16 s, medians of 3, baseline noise ±7%): Clarity → main-thread +100 ms (+6%, consistent every round), heap +4 to +22 MB depending on GC timing, ~6k extra DOM nodes and ~1.9k extra listeners (its DOM mirror), 76 KB download once, ~120 KB uploaded per 30 s active (~4 KB/s); GTM (container `GTM-5KZNZGMN`, no firing tags) → indistinguishable from baseline at runtime, 331 KB download once; both together → additive. Time-to-canvas unchanged in every scenario. Bench script lives in the session scratchpad `bench/bench.js`; drives `/embed` top-level by answering `CLIENT_INIT` with a synthetic `VENDOR_INIT` from an init script, measures via CDP `Performance.getMetrics`. Worst case (155-step flow with 5 loops, Clarity+GTM with a published GA4 tag, heavy scripted use, medians of 3): normal CPU → main-thread +0.2%, 45 fps both, p95 input latency 1288→1296 ms, heap +40 MB (Clarity mirror takes DOM-tracked nodes 9k→33k), 0.9 MB download once (mostly gtm.js+gtag.js), ~280 KB uploaded per ~50 s; 4× CPU throttle → main-thread +4%, 25 fps both, p95 input 4.47→4.45 s, heap +17 MB. Analytics never changed frame rate or input latency; the builder itself is the bottleneck on huge flows (1.3 s to open a step panel at normal CPU, ~4.5 s throttled, with or without tracking).
diff --git a/brain/knowledge/pieces-engine/building-pieces.md b/brain/knowledge/pieces-engine/building-pieces.md
index bf63daa45b0d..2a1490a8736f 100644
--- a/brain/knowledge/pieces-engine/building-pieces.md
+++ b/brain/knowledge/pieces-engine/building-pieces.md
@@ -45,6 +45,10 @@ Authentication, triggers (polling/webhook), properties + validation, flow contro
- **`formatPieceError` runs *inside* the engine's error handler, so anything it throws replaces a real user error with an engine crash.** Two inputs used to do exactly that, both verified: a **cyclic** error body blew `collectMessage`'s recursion with `RangeError: Maximum call stack size exceeded` (a cycle through any of `message`/`detail`/`description`/`reason`/`error`, or a self-referencing array — and a 100-wide cycle made it exponential), and a cyclic `responseBody`/`requestBody` survived extraction only to die at the `JSON.stringify(formatPieceError(...))` every call site wraps it in, with `TypeError: Converting circular structure to JSON`. The second needs no deep cycle at all — a `self` key the extractor never walks is enough. Where that lands is the nasty part: `operations/index.ts` is the outermost handler, so a throw there escapes with no response and the worker just sees an RPC timeout. Both are now bounded — `collectMessageFrom` and `rebuildSerializable` each carry a depth cap plus a visited `WeakSet`. **Sanitize by probing with `JSON.stringify`, never by hand-rolling a walk over every body.** The first attempt here deep-copied unconditionally and silently downgraded exactly the values `JSON.stringify` gets right: `Date` became `{}` instead of an ISO string, `Buffer` became `{"0":104}` instead of `{type,data}`, a custom `toJSON` was ignored, and an own `function` property was promoted to **its source text** where stringify had correctly dropped it. `toSerializable` now tries `JSON.stringify` first and returns the original reference **untouched** when it succeeds, so the common path is byte-identical and uncopied; only a value that actually throws gets the bounded rebuild (cycles become `[Circular]`), and that rebuild is itself wrapped in a `tryCatchSync` that falls back to `[Unserializable]`, because `Object.entries` runs getters and a body with a throwing getter would otherwise escape the guard entirely. Worth knowing that `formatPieceError` output shares references with the caller's error object, and that `ai-provider-health.ts` reads `responseBody` and stringifies it itself — so a copy there is not free. Two consequences to know: a **shared** (non-cyclic) reference is collected once rather than twice, which is free for `JSON.parse`-derived bodies since they never share refs but is a real fidelity loss for live SDK error objects; and the `isFriendlyPieceError` passthrough is *not* re-sanitized, on the contract that such a payload already came from a JSON string. Rule of thumb: this file may return a worse message, never throw.
+- **A vendor placeholder written as `{{1}}` in any prop never reaches `run()`.** The builder's expression engine evaluates every `{{…}}` in step input first, so WhatsApp-style template variables arrive as empty strings and the vendor rejects the request for a reason that looks unrelated (a body/example mismatch). There is no escape a user can type. Accept an alternate marker (`[[1]]`) in the prop, convert it in `run()` (`whatsappClient.normalizeTemplateComponents`), and say so in the prop description.
+- **`src/i18n/translation.json` is generated, not edited.** After adding actions, triggers or descriptions run `TS_NODE_TRANSPILE_ONLY=true npm run cli -- pieces generate-translation-file ` and commit the result. Without the env var ts-node crashes on a TS2339 inside the CLI itself. It rebuilds the piece's `dist` as a side effect.
+- **Bumping a dev piece's version breaks its existing connections until you re-pin `dist/package.json`.** The server resolves piece metadata by the version stored on the connection, and a dev piece serves only the single version in `dist`, so the old version vanishes and every step reports `connection … not found` with a valid connection. Either pin `dist/package.json` back to the stored version after each rebuild, or recreate the connection.
+- **A provider-side webhook handshake only works against a published flow.** `GET /v1/webhooks/{flowId}` answers `404 {}` while the flow is a draft, and the `/draft` and `/test` variants answer `200 {}` without running `onHandshake`, so a provider that verifies the URL at registration time (Meta's `hub.challenge`) reports failure until the flow is published. Any trigger whose instructions show `{{webhookUrl}}` for manual registration must tell the user to publish first (confirmed 2026-09-16 on the WhatsApp triggers).
- **A piece's `build` compiles its `src/**/*.test.ts`, so a type error in a test breaks the piece build while `vitest` stays green.** `npm run build` is `tsc -p tsconfig.lib.json`, whose `include` is `src/**/*.ts` with no test exclusion, while vitest runs through esbuild and strips types without checking them. So a test that passes locally can still fail CI at `#build`, and it takes `validate-publishable-packages` down with it. Run the piece's own `npm run build`, not just its tests, after adding one. Two shapes that bite: `ServerContext` (`{apiUrl, publicUrl, token}`) and `AuthValidationServerContext` (the same minus `token`, plus `mintOidcToken`) are not interchangeable, and `StaticPropsValue` requires *every* prop key to be present, including optional ones — pass `sessionToken: undefined` rather than omitting it.
## Sharing & misc
diff --git a/brain/knowledge/platform-editions-ee/console.md b/brain/knowledge/platform-editions-ee/console.md
new file mode 100644
index 000000000000..c60b4aa18ec0
--- /dev/null
+++ b/brain/knowledge/platform-editions-ee/console.md
@@ -0,0 +1,86 @@
+---
+title: Console
+icon: 🎛️
+---
+
+# Console
+
+The internal admin dashboard, in its **own repo** (`activepieces/console`, private). Not
+customer-facing except for one corner, the Portal. It holds the Autumn master key, mints license
+keys, and is what support and sales actually look at. Activepieces talks to it over
+`AUTUMN_CONSOLE_URL` — see [EE Platform (Plans & Billing)](./ee-platform-plans-billing.md) for
+that contract.
+
+Small: Nx monorepo, bun, three packages, about 8.6k lines of server and 12.4k of web.
+
+**Main DB** — the Supabase project holding `license_keys`, `key_history`, `admin_access`,
+`usage_snapshots`, `usage_limits`, `billing_periods`, `invoices`, read through `supabaseAdmin()`.
+**Chat-analytics DB** — a *second, separate* Supabase project holding `conversations`,
+`conversation_scores`, `audit_log`, read through `chatAnalyticsSupabaseAdmin()`.
+**Architecture A+** — the repo's own name for that split: one codebase, a separate Supabase
+project per sensitive module. DB-level isolation without microservices.
+**Portal** — the customer-facing half at `/portal`. Sign in with email + license key, get a
+self-signed JWT, see your own usage read-only. Easy to forget this repo is not purely internal.
+
+## Stack
+
+| Layer | What |
+|---|---|
+| Server | Fastify 5 + `fastify-type-provider-zod` + `@supabase/supabase-js`. Runs `.ts` under `tsx`, **no bundler** — so its `build` target is `tsc --noEmit`, a typecheck. |
+| Web | React 19, react-router 7 (`BrowserRouter`, not framework mode), Vite 6, Tailwind 4, TanStack Query 5, shadcn/ui on Radix. One flat routes file, no lazy loading. |
+| Shared | Plain TS + zod, no build step, imported as source. |
+| Ports | web **5757**, API **5858**. |
+
+One container serves both: the server registers `@fastify/static` on `packages/web/dist` when it
+exists, with a not-found handler returning `index.html`.
+
+A feature is three files — `-module.ts` (owns the prefix), `-controller.ts` (routes,
+with the route option consts *below* the controller), `-service.ts` (all Supabase access).
+
+## Auth — four kinds, not one
+
+| Hook | Caller | Credential |
+|---|---|---|
+| `supabaseAuthHook` | a person in the admin UI | Google OAuth via Supabase, then an `admin_access` row lookup |
+| `apiKeyAuthHook` | machines | shared secret `AP_API_SECRET_KEY`, constant-time compare |
+| `licenseKeyAuthHook` | AP instances pushing usage | the license key itself as Bearer |
+| `portalAuthHook` | customers | self-signed JWT from `AP_PORTAL_JWT_SECRET` |
+
+Login is **Google only** — there is no password path. `assertAdmin` is a separate role check on
+top. `AP_DEV_AUTH_BYPASS` + `VITE_DEV_AUTH_BYPASS` disables all admin auth for local work.
+
+## Gotchas
+
+- **`core/migrations.ts` does not run migrations.** It probes each declared table/column with a
+ `head: true` select and, when one is missing, **logs the SQL for a human to paste into the
+ Supabase SQL Editor**. Nothing is ever executed. Repeated "Migration pending" lines in the logs
+ are the app asking you to go run DDL by hand. The chat-analytics DB has no detector at all —
+ its schema is a checked-in `chat-analytics-schema.sql` applied manually.
+- **There is no ORM and no Postgres connection.** Every query is HTTP to PostgREST with the
+ `service_role` key; the DB password is unused. So no transactions, no hand-written joins, and
+ `ilike` values must be escaped yourself via `supabase-utils.ts`. There is also no generated
+ `Database` type — the client is untyped and zod at the route boundary is the only enforcement.
+- **`supabase-js` needs Node 22 at runtime, and a Node 20 image fails only after login, never at boot.** `@supabase/realtime-js` (2.112+) asks for a global `WebSocket`, which Node ships unflagged from 22. `SupabaseClient`'s constructor builds a `RealtimeClient` whether or not you use realtime, so on `node:20-alpine` `createClient()` throws `Node.js detected but native WebSocket not found`. Because `supabaseAdmin()` is called lazily inside the request hook rather than at startup, the container boots, passes its health check and looks deployed — then every authenticated request 500s. The generic `INTERNAL_ERROR` body hides it; the stack is only in the Runtime Logs. Pin the runner stage to `node:22-alpine`.
+- **"Access restricted to authorized admin accounts" does not mean your row is missing.** `supabase-auth-hook.ts` reads the `admin_access` row as `const { data: access } = await ...single()` and throws the `error` away, so a missing row, a missing *table*, and an RLS or PostgREST failure all end as the same 401. Before chasing the row, run `select * from admin_access;` in that project's SQL Editor — `relation does not exist` means the schema was never applied (nothing applies it for you; see the migrations gotcha above). If the row is there, compare the email exactly: the lookup is `.eq('user_email', email)` and Postgres text compare is case-sensitive, so `Louai@` never matches `louai@`. `ap-analytics` has the same hook and the same trap.
+- **Never mix the two Supabase clients.** Customer chat data must never reach the main project.
+ A wrong `supabaseAdmin()` in a chat module is a data-isolation break, not a bug.
+- **Production config is a `.env` file on the box, not anything a cloud panel shows you.** There is no secret manager anywhere in the repo — no Doppler, Vault, 1Password or SOPS. The server just reads `process.env[prefix + prop]` (`AP_` for everything, `VITE_` for the two shared ones), and `docker/docker-compose.yml` feeds it with `env_file: ../.env`. So if the app runs on a droplet, DigitalOcean has no environment-variable UI to look in and never will — that is a DO App Platform feature, and a droplet is just a Linux box. To find the values, `docker exec env | grep -E 'AP_|VITE_'`, which works whatever put them there. Check `ls -l .env` is `600` while you are in there: it holds `AP_SUPABASE_SERVICE_ROLE_KEY`, which is full read-write on the billing database.
+- **`VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` are not secrets and are not runtime config.** They are `ARG`s in the Dockerfile, so Vite substitutes them at *build* time and they ship inside the JavaScript the browser downloads — open any `assets/index-*.js` and search for `supabase.co` to read them without any server access. That is intended: the anon key is public by design, and RLS plus the `admin_access` lookup are the real protection. The consequence for CI is that both must be available as build-time secrets, not only on the server. Fastest way to identify which Supabase project an environment points at: open devtools and read the `*.supabase.co` subdomain off any request.
+- **The deploy is not in the repo.** Only a `Dockerfile`, a compose file, and a CI job that lints,
+ typechecks and tests. Where it runs is configured elsewhere — the org's pattern for internal
+ tools is a DigitalOcean droplet driven by Actions over SSH, which you have to read out of
+ `activepieces/discover`'s `deploy.yml`.
+- **The server reads `AP_PORT` and ignores `PORT`, which breaks it on any PaaS.** Its Dockerfile even sets `ENV PORT=3000`, a line that does nothing. This is harmless on a droplet, where you choose the port, and fatal on DigitalOcean App Platform, Railway or Render: they inject `PORT` and health-check it, the app listens somewhere else, and the deploy fails with nothing but 'health check failed'. On App Platform the injected `PORT` is exactly the component's **HTTP Port** setting, so a port pinned in the image is only fatal when the two disagree — and because a boot crash from any missing required env var reports as the same 'health check failed', do not diagnose it as a port problem until the Runtime Logs say so. `ap-analytics` hit exactly this and fixed it by defaulting `PORT` into the prop (`AP_PORT` -> `PORT` -> a local default) and pinning nothing in the image. Do the same here before moving the console off a droplet.
+ That line is dead unless the platform injects `AP_PORT` separately.
+- **App Platform autodetect makes one component per `package.json`, so an Nx monorepo lands as three.** With no `.do/app.yaml` in the repo, DigitalOcean scans and creates a component for `packages/server`, `packages/web` and `packages/shared` — `ap-analytics` came out as two Web Services and a Function for what is a single container. Only one is right: the root `Dockerfile` already serves the API and the built web assets together (`@fastify/static` on `packages/web/dist` with an `index.html` fallback), so keep the component whose Source Directory is `/` and whose build is the Dockerfile, and destroy the rest. The auto-generated names (`2`, `3`) say nothing about which is which — check the Source tab, not the name. Committing a `.do/app.yaml` stops the guessing for good.
+- **Config features belong in `ap-analytics` (the repo that calls itself "config console"), not here** — see decision
+ [000039](../decisions/000041-ai-model-tiers-are-published-to-the-cdn-from-a-second-console.md).
+
+## Key files
+
+- `packages/server/api/src/app/app.ts` — every route prefix in one place
+- `packages/server/api/src/app/core/auth/` — the four hooks
+- `packages/server/api/src/app/core/migrations.ts` — the detector, not a runner
+- `packages/server/api/src/app/modules/license-keys/` — the clearest three-file feature
+- `packages/web/src/features/license-keys/` — the list + detail + dialog shape
+- `packages/web/src/styles.css` — the whole design system; there is no `tailwind.config.js`
diff --git a/brain/knowledge/platform-editions-ee/ee-projects-rbac.md b/brain/knowledge/platform-editions-ee/ee-projects-rbac.md
index ff4d35ffd195..a7505bee25b0 100644
--- a/brain/knowledge/platform-editions-ee/ee-projects-rbac.md
+++ b/brain/knowledge/platform-editions-ee/ee-projects-rbac.md
@@ -26,6 +26,7 @@ Note it lives under `ee/authentication/`, not `ee/projects/`, which is where mos
- **`rbacMiddleware` only covers HTTP routes. Websocket events bypass it.** A socket is authorized once at connect, against the project it claims in the handshake. After that, every resource id arriving in an event payload is just whatever the client typed, and nothing re-checks it. So a socket handler must look the resource up scoped to the connection's project and check the permission itself. Getting this wrong is how `LOCK_RESOURCE` and `JOIN_PRESENCE` shipped letting any member lock, and read the editors of, another project's flows and tables (SRE-218). `collaborative-resource.ts` has the shape to copy.
- **A new `Permission` needs a row in the role grid, or custom roles can never grant it.** The resource-to-permission mapping is a hardcoded array, `groups()` in `packages/web/src/features/members/lib/role-permissions.ts` (it lived inline in `project-role-dialog.tsx` as `initialPermissions` until the Roles & access rebuild), and the grid is a plain `.map()` over it. Default-role grants are hardcoded separately in `access-control-list.ts`, so a permission added there but not here is invisible: ADMIN/EDITOR/VIEWER have it, custom roles cannot be given it, and the feature's tab just never appears for those members. This has already shipped three times — Variables + Knowledge Base (GIT-1751), then Agents, and `PUBLISH_SENSITIVE_FLOW_ACCESS` is missing right now: the grid is **29 of the 30 `Permission` values**, which is why its counter reads "N of 29", and flow-approval rights stay Admin-only. `packages/web/test/features/members/lib/role-permissions.test.ts` now catches the drift — it asserts `PUBLISH_SENSITIVE_FLOW_ACCESS` is the *only* `Permission` absent from the grid, so adding an enum value without a row fails that test.
- **The release diff and the release apply have to share one rule, or a field becomes either invisible or permanently dirty.** `flowDiffService` decides whether a flow changed by comparing a `normalize()`d copy of each side, and `projectStateHelper.updateFlowInProject` decides what actually gets written — two places, and for connection auth they disagreed both ways. `normalize()` blanked `input.auth` before comparing, so re-pointing a step at a different connection and changing nothing else was reported `flowsUnchanged` and never applied, while the connection the release created sat unreferenced at status `MISSING`; and when some other edit did make the diff fire, the apply unconditionally restored the destination's old auth on top of the incoming one (`f196530db6`, Jul 2024 secret preservation that predates externalId placeholders). Patching the two sites separately is the trap: it leaves a destination-has-auth / incoming-has-none step reporting changed on **every** apply forever, because the diff and the apply then hold different ideas of what unchanged means. The fix (GIT-1764) is one exported `mergeDestinationAuth` that both call, so the diff compares the merged incoming version — exactly what apply is about to write. Any future field with "keep the destination's value" semantics wants the same shape. Worth knowing while reading this code: `flowStructureUtil.transferFlow` deep-clones before the callback runs, so mutating `step` inside it never touches the caller's state; connection references persist as the literal `{{connections['']}}`, identical in both projects, which is why comparing them across instances is meaningful at all; and `flowVersionService` recomputes `connectionIds` via `extractConnectionIds` on every mutation, so the version tracks the new reference without anyone updating it by hand.
+- **A project role is deleted by name, so two roles sharing one name lose both.** `DELETE /v1/project-roles/:name` resolves to `repo.delete({ name, platformId })`, which matches every row with that name, and `project_member.projectRole` cascades on `projectRoleId`. `create()` always refused a duplicate, `update()` did not, so renaming B to A was enough to reach that state, and it happened on a dev database without anyone trying. Closed by a unique index on `("platformId", LOWER("name"))` for platform-owned roles, with `getOne` already comparing `LOWER(name)` so the index matches the rule the API enforces. The upgrade renames existing duplicates rather than deleting them: the copy with the most members keeps the name, creation time breaks ties, the rest take the first free ` (2)`, ` (3)` suffix. There is no marker distinguishing a generated name from a chosen one, so `SELECT id, name FROM project_role WHERE name LIKE '% (%)'` lists candidates, not confirmed renames.
- **Piece filtering** now via **piece sets** — `project.pieceSetId` (nullable FK, SET NULL). When `managePiecesEnabled`, new EE projects get the Default set on create; unassigned resolves to Default at filter time. This supersedes the legacy project-plan allow/block list.
- **Worker routing**: `workerGroupId` (bare label, `^[a-z0-9_-]+$`) gated by `workerGroupsEnabled`. When set, the project's `EXECUTE_FLOW`/`EXECUTE_WEBHOOK` jobs route to `project--jobs`; other job types unaffected. Set via `POST /v1/projects/:id`; `GET /v1/projects/worker-groups` (platform-admin) lists online project-scope workers, 402 when flag off.
- `platformProjectService.getForPlatform()`: admins see all, operators see all except others' personal, users see own personal + team projects they're a member of.
diff --git a/brain/knowledge/platform-editions-ee/index.md b/brain/knowledge/platform-editions-ee/index.md
index 987cede997b6..04251d553c09 100644
--- a/brain/knowledge/platform-editions-ee/index.md
+++ b/brain/knowledge/platform-editions-ee/index.md
@@ -47,3 +47,4 @@ Activation/recovery handle for a self-hosted platform's Autumn billing identity
- **License Keys** — activating self-hosted EE
- **Embed** — signing keys, external tokens, the Cloudflare subdomain, and the frame-ancestors CSP
- **Platform Copilot** — retired; kept for the migration trail
+- **Console** — the internal admin dashboard in its own repo: two Supabase projects, four auth hooks, and migrations that only print SQL
diff --git a/bun.lock b/bun.lock
index a1ec9c4f088c..f0fc9471100e 100644
--- a/bun.lock
+++ b/bun.lock
@@ -120,7 +120,7 @@
},
"packages/core/execution": {
"name": "@activepieces/core-execution",
- "version": "0.23.0",
+ "version": "0.23.2",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -152,7 +152,7 @@
},
"packages/core/piece-types": {
"name": "@activepieces/core-piece-types",
- "version": "0.11.1",
+ "version": "0.11.2",
"dependencies": {
"@activepieces/core-utils": "workspace:*",
"tslib": "2.6.2",
@@ -165,7 +165,7 @@
},
"packages/core/shared": {
"name": "@activepieces/shared",
- "version": "0.180.0",
+ "version": "0.183.0",
"dependencies": {
"@activepieces/core-execution": "workspace:*",
"@activepieces/core-formula": "workspace:*",
@@ -188,7 +188,7 @@
},
"packages/core/utils": {
"name": "@activepieces/core-utils",
- "version": "0.8.1",
+ "version": "0.8.2",
"dependencies": {
"deepmerge-ts": "8.0.2",
"ipaddr.js": "2.3.0",
@@ -1066,7 +1066,7 @@
},
"packages/pieces/community/baserow": {
"name": "@activepieces/piece-baserow",
- "version": "0.10.0",
+ "version": "0.11.0",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -3028,7 +3028,7 @@
},
"packages/pieces/community/facebook-pages": {
"name": "@activepieces/piece-facebook-pages",
- "version": "0.2.8",
+ "version": "0.3.0",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -4652,7 +4652,7 @@
},
"packages/pieces/community/jira-cloud": {
"name": "@activepieces/piece-jira-cloud",
- "version": "0.4.0",
+ "version": "0.5.0",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -5400,7 +5400,7 @@
},
"packages/pieces/community/mailer-lite": {
"name": "@activepieces/piece-mailer-lite",
- "version": "0.8.0",
+ "version": "0.9.0",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -6067,7 +6067,7 @@
},
"packages/pieces/community/monday": {
"name": "@activepieces/piece-monday",
- "version": "0.3.8",
+ "version": "0.4.0",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -6986,7 +6986,7 @@
},
"packages/pieces/community/pipedrive": {
"name": "@activepieces/piece-pipedrive",
- "version": "0.9.0",
+ "version": "0.10.0",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -9809,7 +9809,7 @@
},
"packages/pieces/community/whatsapp": {
"name": "@activepieces/piece-whatsapp",
- "version": "0.2.7",
+ "version": "1.0.0",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -9818,6 +9818,7 @@
},
"devDependencies": {
"tslib": "2.6.2",
+ "vitest": "3.2.6",
},
},
"packages/pieces/community/whatsscale": {
@@ -10682,7 +10683,7 @@
},
"packages/pieces/core/webhook": {
"name": "@activepieces/piece-webhook",
- "version": "0.1.41",
+ "version": "0.1.42",
"dependencies": {
"@activepieces/core-piece-types": "workspace:*",
"@activepieces/core-utils": "workspace:*",
@@ -10890,6 +10891,11 @@
"zod": "4.3.6",
},
"devDependencies": {
+ "@activepieces/piece-approval": "workspace:*",
+ "@activepieces/piece-data-mapper": "workspace:*",
+ "@activepieces/piece-delay": "workspace:*",
+ "@activepieces/piece-http": "workspace:*",
+ "@activepieces/piece-webhook": "workspace:*",
"@types/node": "24.11.0",
"deno": "2.9.3",
"esbuild": "0.28.1",
diff --git a/docs/install/reference/breaking-changes.mdx b/docs/install/reference/breaking-changes.mdx
index 1170d8bb627d..976ecd85ce24 100644
--- a/docs/install/reference/breaking-changes.mdx
+++ b/docs/install/reference/breaking-changes.mdx
@@ -89,6 +89,28 @@ Clearing a description from this action is therefore no longer possible; that is
Nothing unless a flow or an agent relied on a blank description to clear a board's description, which previously happened on every run whether or not it was intended. Clear it from Pinterest directly.
+#### WhatsApp Business' Send Message and Send Media return the API response instead of the HTTP envelope
+
+The WhatsApp Business piece returned two different shapes depending on which action you used. `Send Template Message` returned the WhatsApp Cloud API payload directly, while `Send Message` and `Send Media` returned the whole HTTP response, so the same payload sat one level down under `body`, alongside `status` and `headers`.
+
+Both actions now return the API payload directly, matching `Send Template Message` and every other action in the piece. A step that produced
+
+```json
+{ "status": 200, "headers": { }, "body": { "messaging_product": "whatsapp", "messages": [{ "id": "wamid.…" }] } }
+```
+
+now produces
+
+```json
+{ "messaging_product": "whatsapp", "contacts": [{ "input": "…", "wa_id": "…" }], "messages": [{ "id": "wamid.…" }] }
+```
+
+The whole piece also moves to Graph API `v23.0`, via a single `WHATSAPP_API_BASE` constant. It previously split between `v17.0` (expired September 2025) and `v20.0` (expires 24 September 2026).
+
+#### What you need to do
+
+Update any step that reads the output of `Send Message` or `Send Media`. Drop the `body.` prefix: `{{step.body.messages[0].id}}` becomes `{{step.messages[0].id}}`. Steps that only send and ignore the output need no change, and no connection has to be re-authorised.
+
## 0.91.0
### What has changed?
diff --git a/docs/install/reference/environment-variables.mdx b/docs/install/reference/environment-variables.mdx
index 1ed3106f4217..d224dab053a3 100644
--- a/docs/install/reference/environment-variables.mdx
+++ b/docs/install/reference/environment-variables.mdx
@@ -155,6 +155,7 @@ run timeouts, and the network egress posture for user code. Read
| `AP_NETWORK_MODE` | Egress posture for user code. `STRICT` installs the engine's in-process SSRF guard, blocking outbound connections to private, loopback, link-local, and cloud-metadata IPs across every Node egress path (`axios`, `fetch`, `undici`, raw `http`/`net`). This is best-effort, in-process protection — enforce the real boundary in infrastructure (see [Network Security](/install/architecture/network-security)). `UNRESTRICTED` disables the guard. | `UNRESTRICTED` |
| `AP_SSRF_ALLOW_LIST` | Comma-separated IPs or CIDR ranges that bypass `AP_NETWORK_MODE=STRICT`, e.g. `10.0.0.5,10.10.0.0/24`. Only applies when `AP_NETWORK_MODE=STRICT`. | `None` |
| `AP_MODEL_CATALOG_URL` | Source for AI model metadata (context window, pricing, capabilities), shown beside each model when picking one. Fetched once and cached for 24 hours. An install with no egress to the default CDN simply shows models without that metadata; point this at a self-hosted mirror of the same JSON to restore it. | `https://cdn.activepieces.com/ai/model-catalog.json` |
+| `AP_MODEL_TIERS_URL` | Source for the published AI model tiers file (Fast / Expert / Heavy and the model behind each). Read at boot and refreshed every 15 minutes. An install with no egress to the default CDN keeps the tiers shipped with the release and logs a warning at boot. | `https://cdn.activepieces.com/ai/pricing.json` |
---
diff --git a/packages/core/execution/package.json b/packages/core/execution/package.json
index e52c52d05477..1aa2bbaad480 100644
--- a/packages/core/execution/package.json
+++ b/packages/core/execution/package.json
@@ -1,6 +1,6 @@
{
"name": "@activepieces/core-execution",
- "version": "0.23.0",
+ "version": "0.23.2",
"type": "commonjs",
"main": "./dist/src/index.js",
"scripts": {
diff --git a/packages/core/execution/src/lib/workers/worker-contract.ts b/packages/core/execution/src/lib/workers/worker-contract.ts
index 6a907bfb38b8..bca48bf4cb56 100644
--- a/packages/core/execution/src/lib/workers/worker-contract.ts
+++ b/packages/core/execution/src/lib/workers/worker-contract.ts
@@ -90,6 +90,7 @@ export type WorkerToApiContract = {
getAgentConfig(input: GetAgentConfigRequest): Promise
saveAgentMessages(input: SaveAgentMessagesRequest): Promise
saveAgentFile(input: SaveAgentFileRequest): Promise
+ readAgentFile(input: ReadAgentFileRequest): Promise
updateAgentProgress(input: UpdateAgentProgressRequest): Promise
heartbeatAgentConversation(input: HeartbeatAgentConversationRequest): Promise
updateProjectContext(input: UpdateProjectContextRequest): Promise
@@ -156,6 +157,7 @@ export type AgentConfigResponse = {
providerConfigId: string
modelId: string
fastModelId: string
+ imageModelId?: string
systemPrompt: string
messages: unknown[]
allMessages: unknown[]
@@ -189,6 +191,13 @@ export type SaveAgentFileRequest = {
fileName?: string
}
+export type ReadAgentFileRequest = {
+ platformId: string
+ projectId?: string
+ conversationId: string
+ fileId: string
+}
+
export type SaveAgentFileResponse = {
fileId: string
url: string
diff --git a/packages/core/piece-types/package.json b/packages/core/piece-types/package.json
index e63f1dc8304d..674dd6f8e4ea 100644
--- a/packages/core/piece-types/package.json
+++ b/packages/core/piece-types/package.json
@@ -1,6 +1,6 @@
{
"name": "@activepieces/core-piece-types",
- "version": "0.11.1",
+ "version": "0.11.2",
"type": "commonjs",
"main": "./dist/src/index.js",
"scripts": {
diff --git a/packages/core/piece-types/src/lib/ai-providers.ts b/packages/core/piece-types/src/lib/ai-providers.ts
index ea9e8810452f..eb7480dfa7e2 100644
--- a/packages/core/piece-types/src/lib/ai-providers.ts
+++ b/packages/core/piece-types/src/lib/ai-providers.ts
@@ -365,6 +365,7 @@ function buildProviderCapabilities(provider: AIProviderName): AIProviderCapabili
defaultEmbeddingModel: DEFAULT_EMBEDDING_MODELS[provider],
supportsEmbedding: DEFAULT_EMBEDDING_MODELS[provider] !== undefined,
supportsImageGeneration: !NO_IMAGE_GENERATION_PROVIDERS.has(provider),
+ defaultImageModel: DEFAULT_IMAGE_MODELS[provider],
webSearch: WEB_SEARCH_MODE_BY_PROVIDER[provider],
}
}
@@ -387,6 +388,13 @@ export const ACTIVEPIECES_IMAGE_TIERS = [
export type ActivepiecesImageTier = typeof ACTIVEPIECES_IMAGE_TIERS[number]
+const DEFAULT_IMAGE_MODELS: Partial> = {
+ [AIProviderName.ACTIVEPIECES]: ACTIVEPIECES_IMAGE_TIERS[0].modelId,
+ [AIProviderName.OPENROUTER]: 'google/gemini-3.1-flash-lite-image',
+ [AIProviderName.OPENAI]: 'gpt-image-1.5',
+ [AIProviderName.GOOGLE]: 'gemini-2.5-flash-image',
+}
+
export const AI_PROVIDER_CAPABILITIES: Record = {
[AIProviderName.OPENAI]: buildProviderCapabilities(AIProviderName.OPENAI),
[AIProviderName.ANTHROPIC]: buildProviderCapabilities(AIProviderName.ANTHROPIC),
@@ -437,5 +445,6 @@ export type AIProviderCapabilities = {
defaultEmbeddingModel: string | undefined
supportsEmbedding: boolean
supportsImageGeneration: boolean
+ defaultImageModel: string | undefined
webSearch: AIWebSearchMode | undefined
}
diff --git a/packages/core/shared/package.json b/packages/core/shared/package.json
index 85f0c5826b7a..21fcd1ea9dd2 100644
--- a/packages/core/shared/package.json
+++ b/packages/core/shared/package.json
@@ -1,6 +1,6 @@
{
"name": "@activepieces/shared",
- "version": "0.181.0",
+ "version": "0.183.0",
"type": "commonjs",
"sideEffects": false,
"main": "./dist/src/index.js",
diff --git a/packages/core/shared/src/lib/automation/mcp/mcp.ts b/packages/core/shared/src/lib/automation/mcp/mcp.ts
index 6d4d88a88906..fc2cc6403a2b 100644
--- a/packages/core/shared/src/lib/automation/mcp/mcp.ts
+++ b/packages/core/shared/src/lib/automation/mcp/mcp.ts
@@ -25,6 +25,16 @@ export const PopulatedMcpServer = McpServer.extend({
})
export type PopulatedMcpServer = z.infer
+export const ProjectMcpServerResponse = PopulatedMcpServer.extend({
+ platformDisabledTools: z.array(z.string()),
+})
+export type ProjectMcpServerResponse = z.infer
+
+export const McpReachResponse = z.object({
+ projectIds: z.array(ApId).nullable(),
+})
+export type McpReachResponse = z.infer
+
export type McpServer = z.infer
export type ProjectScopedMcpServer = McpServer & { projectId: string }
diff --git a/packages/core/utils/package.json b/packages/core/utils/package.json
index 84db8f954ae3..038de4dadb1e 100644
--- a/packages/core/utils/package.json
+++ b/packages/core/utils/package.json
@@ -1,6 +1,6 @@
{
"name": "@activepieces/core-utils",
- "version": "0.8.1",
+ "version": "0.8.2",
"type": "commonjs",
"main": "./dist/src/index.js",
"scripts": {
diff --git a/packages/core/utils/src/lib/ai-provider-health.ts b/packages/core/utils/src/lib/ai-provider-health.ts
index 225882fe013e..6f787ae8760d 100644
--- a/packages/core/utils/src/lib/ai-provider-health.ts
+++ b/packages/core/utils/src/lib/ai-provider-health.ts
@@ -138,7 +138,7 @@ function isNil(value: T | null | undefined): value is null | undefined {
const MAX_OBSERVED_BODY_LENGTH = 2000
const CREDIT_ERROR_PATTERNS = [/credits/i, /\b402\b/, /payment.required/i]
-const TRANSIENT_ERROR_PATTERN = /\b(429|5\d\d)\b|rate.?limit|timeout|timed out|temporarily|econnreset|etimedout|socket hang up|service unavailable/i
+const TRANSIENT_ERROR_PATTERN = /\b(429|5\d\d)\b|rate.?limit|timeout|timed out|temporarily|econnreset|etimedout|socket hang up|service unavailable|aborted/i
const BILLING_BODY_PATTERN = /insufficient_quota|credit[_ ]balance|billing_hard_limit_reached|billing|\bcredits?\b|out of funds|payment required/i
diff --git a/packages/core/utils/test/ai-provider-health.test.ts b/packages/core/utils/test/ai-provider-health.test.ts
index 71acf9f22f68..aabc7c32a0c3 100644
--- a/packages/core/utils/test/ai-provider-health.test.ts
+++ b/packages/core/utils/test/ai-provider-health.test.ts
@@ -76,6 +76,7 @@ describe('classifyProviderOutcome', () => {
expect(classifyProviderOutcome({ message: '429 rate limit exceeded' })).toBe('no_change')
expect(classifyProviderOutcome({ message: 'socket hang up' })).toBe('no_change')
expect(classifyProviderOutcome({ message: 'ETIMEDOUT' })).toBe('no_change')
+ expect(classifyProviderOutcome({ message: 'This operation was aborted' })).toBe('no_change')
})
it('treats an unrecognised failure as unreachable', () => {
diff --git a/packages/pieces/community/mailer-lite/package.json b/packages/pieces/community/mailer-lite/package.json
index 84e900995021..7eb42e951b5c 100644
--- a/packages/pieces/community/mailer-lite/package.json
+++ b/packages/pieces/community/mailer-lite/package.json
@@ -1,6 +1,6 @@
{
"name": "@activepieces/piece-mailer-lite",
- "version": "0.8.0",
+ "version": "0.9.0",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"dependencies": {
diff --git a/packages/pieces/community/mailer-lite/src/i18n/translation.json b/packages/pieces/community/mailer-lite/src/i18n/translation.json
index dce3157cf14e..6121cf42ce74 100644
--- a/packages/pieces/community/mailer-lite/src/i18n/translation.json
+++ b/packages/pieces/community/mailer-lite/src/i18n/translation.json
@@ -18,7 +18,6 @@
"Removes subscriber from a specific group.": "Removes subscriber from a specific group.",
"List subscribers, optionally filtered by status.": "List subscribers, optionally filtered by status.",
"Set a subscriber status to unsubscribed.": "Set a subscriber status to unsubscribed.",
- "Permanently delete a subscriber.": "Permanently delete a subscriber.",
"List all groups in the account.": "List all groups in the account.",
"Create a new group.": "Create a new group.",
"List the subscribers belonging to a group.": "List the subscribers belonging to a group.",
@@ -75,5 +74,251 @@
"Triggers when the subscriber fields have been updated.": "Triggers when the subscriber fields have been updated.",
"Triggers when a subscriber has unsubscribed from your mailing list.": "Triggers when a subscriber has unsubscribed from your mailing list.",
"Triggers when a subscriber is added to a group.": "Triggers when a subscriber is added to a group.",
- "Webhook Name": "Webhook Name"
-}
\ No newline at end of file
+ "Webhook Name": "Webhook Name",
+ "Update Subscriber": "Update Subscriber",
+ "Update fields or status of an existing subscriber.": "Update fields or status of an existing subscriber.",
+ "The subscriber ID, from find_subscriber or list_subscribers.": "The subscriber ID, from find_subscriber or list_subscribers.",
+ "Field values to set, keyed by field key (e.g. name, last_name, or a custom field key from list_fields). Fields not listed are left alone.": "Field values to set, keyed by field key (e.g. name, last_name, or a custom field key from list_fields). Fields not listed are left alone.",
+ "New subscriber status. Unsubscribed, bounced and junk subscribers cannot be reactivated through the API.": "New subscriber status. Unsubscribed, bounced and junk subscribers cannot be reactivated through the API.",
+ "Subscribed At": "Subscribed At",
+ "When the subscriber subscribed.": "When the subscriber subscribed.",
+ "Opted In At": "Opted In At",
+ "When the subscriber confirmed the opt-in.": "When the subscriber confirmed the opt-in.",
+ "Unsubscribed At": "Unsubscribed At",
+ "When the subscriber unsubscribed.": "When the subscriber unsubscribed.",
+ "IP Address": "IP Address",
+ "The IP address the subscriber signed up from.": "The IP address the subscriber signed up from.",
+ "Opt-in IP": "Opt-in IP",
+ "The IP address the subscriber confirmed the opt-in from.": "The IP address the subscriber confirmed the opt-in from.",
+ "Forget Subscriber (GDPR)": "Forget Subscriber (GDPR)",
+ "Remove a subscriber and permanently erase their data (GDPR).": "Remove a subscriber and permanently erase their data (GDPR).",
+ "Get Subscriber Activity": "Get Subscriber Activity",
+ "List the activity log of a subscriber.": "List the activity log of a subscriber.",
+ "Activity Type": "Activity Type",
+ "Only return this type of activity.": "Only return this type of activity.",
+ "Campaign send": "Campaign send",
+ "Automation email sent": "Automation email sent",
+ "Email open": "Email open",
+ "Link click": "Link click",
+ "Email bounce": "Email bounce",
+ "Spam complaint": "Spam complaint",
+ "Email forward": "Email forward",
+ "Marketing preferences change": "Marketing preferences change",
+ "Preference center": "Preference center",
+ "Items to return (1-100, default 100).": "Items to return (1-100, default 100).",
+ "Page": "Page",
+ "Page number to return, starting at 1.": "Page number to return, starting at 1.",
+ "Count Subscribers": "Count Subscribers",
+ "Get the total number of subscribers in the account.": "Get the total number of subscribers in the account.",
+ "Import Subscribers to Group": "Import Subscribers to Group",
+ "Start a bulk import of subscribers into a group.": "Start a bulk import of subscribers into a group.",
+ "The group ID, from list_groups or find_groups_by_name.": "The group ID, from list_groups or find_groups_by_name.",
+ "Subscribers": "Subscribers",
+ "JSON array of subscribers, e.g. [{\"email\": \"a@example.com\", \"fields\": {\"name\": \"Ann\"}}].": "JSON array of subscribers, e.g. [{\"email\": \"a@example.com\", \"fields\": {\"name\": \"Ann\"}}].",
+ "Get Import Status": "Get Import Status",
+ "Get the progress and report of a subscriber import.": "Get the progress and report of a subscriber import.",
+ "Import ID": "Import ID",
+ "The import ID returned by import_subscribers_to_group.": "The import ID returned by import_subscribers_to_group.",
+ "Find Groups by Name": "Find Groups by Name",
+ "Find subscriber groups whose name contains a text.": "Find subscriber groups whose name contains a text.",
+ "Name Contains": "Name Contains",
+ "Text the group name must contain.": "Text the group name must contain.",
+ "Sort": "Sort",
+ "Sort order; a minus prefix means descending.": "Sort order; a minus prefix means descending.",
+ "Name": "Name",
+ "Name (desc)": "Name (desc)",
+ "Total": "Total",
+ "Total (desc)": "Total (desc)",
+ "Open rate": "Open rate",
+ "Open rate (desc)": "Open rate (desc)",
+ "Click rate": "Click rate",
+ "Click rate (desc)": "Click rate (desc)",
+ "Created at": "Created at",
+ "Created at (desc)": "Created at (desc)",
+ "Items to return (1-1000, default 25).": "Items to return (1-1000, default 25).",
+ "Rename Group": "Rename Group",
+ "Rename a subscriber group.": "Rename a subscriber group.",
+ "The new group name (max 255 characters).": "The new group name (max 255 characters).",
+ "Delete Group": "Delete Group",
+ "Delete a subscriber group.": "Delete a subscriber group.",
+ "List Segments": "List Segments",
+ "List the segments in the account.": "List the segments in the account.",
+ "Items to return (1-250, default 50).": "Items to return (1-250, default 50).",
+ "List Segment Subscribers": "List Segment Subscribers",
+ "List the subscribers that match a segment.": "List the subscribers that match a segment.",
+ "Segment ID": "Segment ID",
+ "The segment ID, from list_segments.": "The segment ID, from list_segments.",
+ "Only return subscribers with this status. MailerLite defaults to active.": "Only return subscribers with this status. MailerLite defaults to active.",
+ "Items to return (1-1000, default 50).": "Items to return (1-1000, default 50).",
+ "Cursor": "Cursor",
+ "The meta.next_cursor value from the previous page. Leave empty for the first page.": "The meta.next_cursor value from the previous page. Leave empty for the first page.",
+ "Rename Segment": "Rename Segment",
+ "Rename a segment.": "Rename a segment.",
+ "The new segment name (max 255 characters).": "The new segment name (max 255 characters).",
+ "Delete Segment": "Delete Segment",
+ "Delete a segment.": "Delete a segment.",
+ "List Fields": "List Fields",
+ "List the subscriber fields in the account.": "List the subscriber fields in the account.",
+ "Keyword": "Keyword",
+ "Only return fields whose name contains this text.": "Only return fields whose name contains this text.",
+ "Type": "Type",
+ "Only return fields of this type.": "Only return fields of this type.",
+ "Text": "Text",
+ "Number": "Number",
+ "Date": "Date",
+ "Create Field": "Create Field",
+ "Create a custom subscriber field.": "Create a custom subscriber field.",
+ "The field name.": "The field name.",
+ "The field type. It cannot be changed later.": "The field type. It cannot be changed later.",
+ "Rename Field": "Rename Field",
+ "Rename a custom field.": "Rename a custom field.",
+ "Field ID": "Field ID",
+ "The custom field ID, from list_fields.": "The custom field ID, from list_fields.",
+ "The new field name.": "The new field name.",
+ "Delete Field": "Delete Field",
+ "Delete a custom field.": "Delete a custom field.",
+ "List Automations": "List Automations",
+ "List the automations in the account.": "List the automations in the account.",
+ "Enabled": "Enabled",
+ "Only return enabled or disabled automations.": "Only return enabled or disabled automations.",
+ "Disabled": "Disabled",
+ "Only return automations whose name contains this text.": "Only return automations whose name contains this text.",
+ "Only return automations that use this group, from list_groups.": "Only return automations that use this group, from list_groups.",
+ "Items to return (1-100, default 10).": "Items to return (1-100, default 10).",
+ "Get Automation": "Get Automation",
+ "Get an automation by ID.": "Get an automation by ID.",
+ "Automation ID": "Automation ID",
+ "The automation ID, from list_automations.": "The automation ID, from list_automations.",
+ "Get Automation Activity": "Get Automation Activity",
+ "List subscriber activity in an automation.": "List subscriber activity in an automation.",
+ "Which activity to return.": "Which activity to return.",
+ "Completed": "Completed",
+ "Canceled": "Canceled",
+ "Failed": "Failed",
+ "Date From": "Date From",
+ "Finished on or after this date (YYYY-MM-DD).": "Finished on or after this date (YYYY-MM-DD).",
+ "Date To": "Date To",
+ "Finished on or before this date (YYYY-MM-DD).": "Finished on or before this date (YYYY-MM-DD).",
+ "Scheduled From": "Scheduled From",
+ "Active runs scheduled on or after this date (YYYY-MM-DD).": "Active runs scheduled on or after this date (YYYY-MM-DD).",
+ "Scheduled To": "Scheduled To",
+ "Active runs scheduled on or before this date (YYYY-MM-DD).": "Active runs scheduled on or before this date (YYYY-MM-DD).",
+ "Subscriber Email": "Subscriber Email",
+ "Only return activity for subscribers whose email matches this text.": "Only return activity for subscribers whose email matches this text.",
+ "Create Automation Draft": "Create Automation Draft",
+ "Create an empty, disabled automation draft.": "Create an empty, disabled automation draft.",
+ "The automation name.": "The automation name.",
+ "Delete Automation": "Delete Automation",
+ "Delete an automation.": "Delete an automation.",
+ "List Campaigns": "List Campaigns",
+ "List campaigns, optionally filtered by status and type.": "List campaigns, optionally filtered by status and type.",
+ "Only return campaigns with this status. MailerLite defaults to ready.": "Only return campaigns with this status. MailerLite defaults to ready.",
+ "Draft": "Draft",
+ "Ready": "Ready",
+ "Sent": "Sent",
+ "Only return campaigns of this type.": "Only return campaigns of this type.",
+ "Regular": "Regular",
+ "A/B split": "A/B split",
+ "Resend": "Resend",
+ "RSS": "RSS",
+ "Items to return (1-100, default 25).": "Items to return (1-100, default 25).",
+ "Get Campaign": "Get Campaign",
+ "Get a campaign by ID.": "Get a campaign by ID.",
+ "Campaign ID": "Campaign ID",
+ "The campaign ID, from list_campaigns.": "The campaign ID, from list_campaigns.",
+ "Create Campaign Draft": "Create Campaign Draft",
+ "Create a regular email campaign as a draft. It is never sent.": "Create a regular email campaign as a draft. It is never sent.",
+ "Campaign Name": "Campaign Name",
+ "Internal campaign name.": "Internal campaign name.",
+ "Subject": "Subject",
+ "Email subject line.": "Email subject line.",
+ "From Name": "From Name",
+ "Sender name shown to recipients.": "Sender name shown to recipients.",
+ "From Email": "From Email",
+ "Sender email. Must be a verified sender on the MailerLite account.": "Sender email. Must be a verified sender on the MailerLite account.",
+ "Reply-To Email": "Reply-To Email",
+ "Reply-to email address.": "Reply-to email address.",
+ "Groups to send to, from list_groups.": "Groups to send to, from list_groups.",
+ "Segment IDs": "Segment IDs",
+ "Segments to send to, from list_segments.": "Segments to send to, from list_segments.",
+ "Language ID": "Language ID",
+ "Campaign language ID, from list_campaign_languages.": "Campaign language ID, from list_campaign_languages.",
+ "HTML Content": "HTML Content",
+ "Email HTML. Advanced plan only; omit on other plans.": "Email HTML. Advanced plan only; omit on other plans.",
+ "Update Campaign Draft": "Update Campaign Draft",
+ "Update a draft campaign, keeping the values you do not change.": "Update a draft campaign, keeping the values you do not change.",
+ "The draft campaign ID, from list_campaigns.": "The draft campaign ID, from list_campaigns.",
+ "New internal campaign name.": "New internal campaign name.",
+ "New email subject line.": "New email subject line.",
+ "New sender name.": "New sender name.",
+ "New sender email. Must be a verified sender on the MailerLite account.": "New sender email. Must be a verified sender on the MailerLite account.",
+ "New reply-to email address.": "New reply-to email address.",
+ "Replaces the audience with these groups, from list_groups. Not with Segment IDs.": "Replaces the audience with these groups, from list_groups. Not with Segment IDs.",
+ "Replaces the audience with these segments, from list_segments. Not with Group IDs.": "Replaces the audience with these segments, from list_segments. Not with Group IDs.",
+ "Delete Campaign": "Delete Campaign",
+ "Delete a campaign.": "Delete a campaign.",
+ "List Campaign Languages": "List Campaign Languages",
+ "List the languages available for campaigns.": "List the languages available for campaigns.",
+ "List Forms": "List Forms",
+ "List signup forms of one type.": "List signup forms of one type.",
+ "Form Type": "Form Type",
+ "The type of forms to list.": "The type of forms to list.",
+ "Popup": "Popup",
+ "Embedded": "Embedded",
+ "Promotion": "Promotion",
+ "Only return forms whose name contains this text.": "Only return forms whose name contains this text.",
+ "Get Form": "Get Form",
+ "Get a form by ID.": "Get a form by ID.",
+ "Form ID": "Form ID",
+ "The form ID, from list_forms.": "The form ID, from list_forms.",
+ "List Form Subscribers": "List Form Subscribers",
+ "List the subscribers who signed up through a form.": "List the subscribers who signed up through a form.",
+ "List Webhooks": "List Webhooks",
+ "List the webhooks in the account.": "List the webhooks in the account.",
+ "Get Webhook": "Get Webhook",
+ "Get a webhook by ID.": "Get a webhook by ID.",
+ "Webhook ID": "Webhook ID",
+ "The webhook ID, from list_webhooks.": "The webhook ID, from list_webhooks.",
+ "Create Webhook": "Create Webhook",
+ "Create a webhook that posts MailerLite events to a URL.": "Create a webhook that posts MailerLite events to a URL.",
+ "URL": "URL",
+ "The HTTPS URL MailerLite will POST events to.": "The HTTPS URL MailerLite will POST events to.",
+ "Events": "Events",
+ "The events that trigger the webhook.": "The events that trigger the webhook.",
+ "A name for the webhook.": "A name for the webhook.",
+ "Whether the webhook is active. MailerLite defaults to enabled.": "Whether the webhook is active. MailerLite defaults to enabled.",
+ "Batchable": "Batchable",
+ "Whether events are delivered in batches.": "Whether events are delivered in batches.",
+ "Update Webhook": "Update Webhook",
+ "Update a webhook, changing only the values you supply.": "Update a webhook, changing only the values you supply.",
+ "New webhook name.": "New webhook name.",
+ "New HTTPS URL MailerLite will POST events to.": "New HTTPS URL MailerLite will POST events to.",
+ "Replaces the events that trigger the webhook.": "Replaces the events that trigger the webhook.",
+ "Whether the webhook is active.": "Whether the webhook is active.",
+ "Delete Webhook": "Delete Webhook",
+ "Delete a webhook.": "Delete a webhook.",
+ "List Timezones": "List Timezones",
+ "List the timezones MailerLite supports.": "List the timezones MailerLite supports.",
+ "Subscribers to return (1-1000, default 25).": "Subscribers to return (1-1000, default 25).",
+ "Group": "Group",
+ "Delete a subscriber. MailerLite keeps their data in case they re-subscribe.": "Delete a subscriber. MailerLite keeps their data in case they re-subscribe.",
+ "Yes": "Yes",
+ "No": "No",
+ "subscriber.created": "subscriber.created",
+ "subscriber.updated": "subscriber.updated",
+ "subscriber.unsubscribed": "subscriber.unsubscribed",
+ "subscriber.added_to_group": "subscriber.added_to_group",
+ "subscriber.removed_from_group": "subscriber.removed_from_group",
+ "subscriber.bounced": "subscriber.bounced",
+ "subscriber.automation_triggered": "subscriber.automation_triggered",
+ "subscriber.automation_completed": "subscriber.automation_completed",
+ "subscriber.spam_reported": "subscriber.spam_reported",
+ "subscriber.deleted": "subscriber.deleted",
+ "subscriber.active": "subscriber.active",
+ "campaign.sent": "campaign.sent",
+ "campaign.click": "campaign.click",
+ "campaign.open": "campaign.open",
+ "List groups in the account, one page at a time.": "List groups in the account, one page at a time.",
+ "Groups to return per page (1-1000, default 100).": "Groups to return per page (1-1000, default 100).",
+ "Page number to return, starting at 1. Use `meta.last_page` from a previous response to know when to stop.": "Page number to return, starting at 1. Use `meta.last_page` from a previous response to know when to stop."
+}
diff --git a/packages/pieces/community/mailer-lite/src/index.ts b/packages/pieces/community/mailer-lite/src/index.ts
index fe44639cc02a..f416ac6054e2 100644
--- a/packages/pieces/community/mailer-lite/src/index.ts
+++ b/packages/pieces/community/mailer-lite/src/index.ts
@@ -12,6 +12,43 @@ import { listGroupSubscribersAction } from './lib/actions/list-group-subscribers
import { listGroupsAction } from './lib/actions/list-groups';
import { listSubscribersAction } from './lib/actions/list-subscribers';
import { unsubscribeSubscriberAction } from './lib/actions/unsubscribe-subscriber';
+import { updateSubscriberAction } from './lib/actions/update-subscriber';
+import { forgetSubscriberAction } from './lib/actions/forget-subscriber';
+import { getSubscriberActivityAction } from './lib/actions/get-subscriber-activity';
+import { countSubscribersAction } from './lib/actions/count-subscribers';
+import { importSubscribersToGroupAction } from './lib/actions/import-subscribers-to-group';
+import { getImportStatusAction } from './lib/actions/get-import-status';
+import { findGroupsByNameAction } from './lib/actions/find-groups-by-name';
+import { renameGroupAction } from './lib/actions/rename-group';
+import { deleteGroupAction } from './lib/actions/delete-group';
+import { listSegmentsAction } from './lib/actions/list-segments';
+import { listSegmentSubscribersAction } from './lib/actions/list-segment-subscribers';
+import { renameSegmentAction } from './lib/actions/rename-segment';
+import { deleteSegmentAction } from './lib/actions/delete-segment';
+import { listFieldsAction } from './lib/actions/list-fields';
+import { createFieldAction } from './lib/actions/create-field';
+import { renameFieldAction } from './lib/actions/rename-field';
+import { deleteFieldAction } from './lib/actions/delete-field';
+import { listAutomationsAction } from './lib/actions/list-automations';
+import { getAutomationAction } from './lib/actions/get-automation';
+import { getAutomationActivityAction } from './lib/actions/get-automation-activity';
+import { createAutomationAction } from './lib/actions/create-automation';
+import { deleteAutomationAction } from './lib/actions/delete-automation';
+import { listCampaignsAction } from './lib/actions/list-campaigns';
+import { getCampaignAction } from './lib/actions/get-campaign';
+import { createCampaignDraftAction } from './lib/actions/create-campaign-draft';
+import { updateCampaignDraftAction } from './lib/actions/update-campaign-draft';
+import { deleteCampaignAction } from './lib/actions/delete-campaign';
+import { listCampaignLanguagesAction } from './lib/actions/list-campaign-languages';
+import { listFormsAction } from './lib/actions/list-forms';
+import { getFormAction } from './lib/actions/get-form';
+import { listFormSubscribersAction } from './lib/actions/list-form-subscribers';
+import { listWebhooksAction } from './lib/actions/list-webhooks';
+import { getWebhookAction } from './lib/actions/get-webhook';
+import { createWebhookAction } from './lib/actions/create-webhook';
+import { updateWebhookAction } from './lib/actions/update-webhook';
+import { deleteWebhookAction } from './lib/actions/delete-webhook';
+import { listTimezonesAction } from './lib/actions/list-timezones';
import { mailerLiteAuth } from './lib/auth';
export { mailerLiteAuth };
@@ -20,7 +57,7 @@ export const mailerLite = createPiece({
displayName: 'MailerLite',
description: 'Email marketing software',
- minimumSupportedRelease: '0.30.0',
+ minimumSupportedRelease: '0.88.2',
logoUrl: 'https://cdn.activepieces.com/pieces/mailer-lite.png',
categories: [PieceCategory.MARKETING],
authors: ["Willianwg","kanarelo","kishanprmr","khaledmashaly","abuaboud"],
@@ -36,6 +73,43 @@ export const mailerLite = createPiece({
listGroupsAction,
createGroupAction,
listGroupSubscribersAction,
+ updateSubscriberAction,
+ forgetSubscriberAction,
+ getSubscriberActivityAction,
+ countSubscribersAction,
+ importSubscribersToGroupAction,
+ getImportStatusAction,
+ findGroupsByNameAction,
+ renameGroupAction,
+ deleteGroupAction,
+ listSegmentsAction,
+ listSegmentSubscribersAction,
+ renameSegmentAction,
+ deleteSegmentAction,
+ listFieldsAction,
+ createFieldAction,
+ renameFieldAction,
+ deleteFieldAction,
+ listAutomationsAction,
+ getAutomationAction,
+ getAutomationActivityAction,
+ createAutomationAction,
+ deleteAutomationAction,
+ listCampaignsAction,
+ getCampaignAction,
+ createCampaignDraftAction,
+ updateCampaignDraftAction,
+ deleteCampaignAction,
+ listCampaignLanguagesAction,
+ listFormsAction,
+ getFormAction,
+ listFormSubscribersAction,
+ listWebhooksAction,
+ getWebhookAction,
+ createWebhookAction,
+ updateWebhookAction,
+ deleteWebhookAction,
+ listTimezonesAction,
createCustomApiCallAction({
baseUrl: () => 'https://connect.mailerlite.com/',
auth: mailerLiteAuth,
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/count-subscribers.ts b/packages/pieces/community/mailer-lite/src/lib/actions/count-subscribers.ts
new file mode 100644
index 000000000000..c631117062d7
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/count-subscribers.ts
@@ -0,0 +1,39 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { countSubscribersOutputSchema } from '../output-schemas';
+
+export const countSubscribersAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'count_subscribers',
+ classification: 'READ',
+ displayName: 'Count Subscribers',
+ description: 'Get the total number of subscribers in the account.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Get the total number of subscribers in the MailerLite account without listing them. Read-only and idempotent.',
+ idempotent: true,
+ },
+ outputSchema: countSubscribersOutputSchema,
+ props: {},
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/subscribers',
+ queryParams: { limit: 0 },
+ });
+ if (!mailerLiteApi.isRecord(body)) {
+ throw new Error('MailerLite returned an unexpected response for the subscriber count.');
+ }
+ const meta = body['meta'];
+ const rawTotal = body['total'] ?? (mailerLiteApi.isRecord(meta) ? meta['total'] : undefined);
+ const total = Number(rawTotal);
+ if (rawTotal === undefined || rawTotal === null || !Number.isFinite(total)) {
+ throw new Error('MailerLite did not return a subscriber total.');
+ }
+ return { total };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/create-automation.ts b/packages/pieces/community/mailer-lite/src/lib/actions/create-automation.ts
new file mode 100644
index 000000000000..d0b8090ee71f
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/create-automation.ts
@@ -0,0 +1,40 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { createAutomationOutputSchema } from '../output-schemas';
+
+export const createAutomationAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'create_automation',
+ classification: 'WRITE',
+ displayName: 'Create Automation Draft',
+ description: 'Create an empty, disabled automation draft.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Create an empty MailerLite automation draft with only a name. It is disabled and has no trigger or steps; those are configured in the MailerLite dashboard. Nothing is sent. Not idempotent: each call creates another draft.',
+ idempotent: false,
+ },
+ outputSchema: createAutomationOutputSchema,
+ props: {
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'The automation name.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const name = context.propsValue.name.trim();
+ if (!name) {
+ throw new Error('Name is required.');
+ }
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.POST,
+ path: '/automations',
+ body: { name },
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/create-campaign-draft.ts b/packages/pieces/community/mailer-lite/src/lib/actions/create-campaign-draft.ts
new file mode 100644
index 000000000000..90cadb834ce0
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/create-campaign-draft.ts
@@ -0,0 +1,103 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { campaignWriteOutputSchema } from '../output-schemas';
+
+function toIdList(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value.map((item) => String(item).trim()).filter((item) => item.length > 0);
+}
+
+export const createCampaignDraftAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'create_campaign_draft',
+ classification: 'WRITE',
+ displayName: 'Create Campaign Draft',
+ description: 'Create a regular email campaign as a draft. It is never sent.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Create a regular MailerLite email campaign as a draft only; it is never sent or scheduled, sending is done in the MailerLite dashboard. from must be a verified sender email on the account. Target it with group_ids (from list_groups) and/or segment_ids (from list_segments); with neither, the draft has no audience yet. language_id comes from list_campaign_languages. content (HTML) works on the Advanced plan only; omit it on other plans. Not idempotent: each call creates another draft.',
+ idempotent: false,
+ },
+ outputSchema: campaignWriteOutputSchema,
+ props: {
+ name: Property.ShortText({
+ displayName: 'Campaign Name',
+ description: 'Internal campaign name.',
+ required: true,
+ }),
+ subject: Property.ShortText({
+ displayName: 'Subject',
+ description: 'Email subject line.',
+ required: true,
+ }),
+ from_name: Property.ShortText({
+ displayName: 'From Name',
+ description: 'Sender name shown to recipients.',
+ required: true,
+ }),
+ from: Property.ShortText({
+ displayName: 'From Email',
+ description: 'Sender email. Must be a verified sender on the MailerLite account.',
+ required: true,
+ }),
+ reply_to: Property.ShortText({
+ displayName: 'Reply-To Email',
+ description: 'Reply-to email address.',
+ required: false,
+ }),
+ group_ids: Property.Array({
+ displayName: 'Group IDs',
+ description: 'Groups to send to, from list_groups.',
+ required: false,
+ }),
+ segment_ids: Property.Array({
+ displayName: 'Segment IDs',
+ description: 'Segments to send to, from list_segments.',
+ required: false,
+ }),
+ language_id: Property.ShortText({
+ displayName: 'Language ID',
+ description: 'Campaign language ID, from list_campaign_languages.',
+ required: false,
+ }),
+ content: Property.LongText({
+ displayName: 'HTML Content',
+ description: 'Email HTML. Advanced plan only; omit on other plans.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const { name, subject, from_name, from, reply_to, language_id, content } = context.propsValue;
+ const email: Record = { subject, from_name, from };
+ if (reply_to) {
+ email['reply_to'] = reply_to;
+ }
+ if (content) {
+ email['content'] = content;
+ }
+ const body: Record = { name, type: 'regular', emails: [email] };
+ const groups = toIdList(context.propsValue.group_ids);
+ const segments = toIdList(context.propsValue.segment_ids);
+ if (groups.length > 0) {
+ body['groups'] = groups;
+ }
+ if (segments.length > 0) {
+ body['segments'] = segments;
+ }
+ if (language_id) {
+ body['language_id'] = language_id;
+ }
+ const response = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.POST,
+ path: '/campaigns',
+ body,
+ });
+ return mailerLiteApi.unwrapData(response);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/create-field.ts b/packages/pieces/community/mailer-lite/src/lib/actions/create-field.ts
new file mode 100644
index 000000000000..68b3677401d5
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/create-field.ts
@@ -0,0 +1,52 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { fieldOutputSchema } from '../output-schemas';
+
+export const createFieldAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'create_field',
+ classification: 'WRITE',
+ displayName: 'Create Field',
+ description: 'Create a custom subscriber field.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Create a MailerLite custom subscriber field with a name and type (text, number or date). The type cannot be changed later. Returns the field with its key, which is what goes into a subscriber\'s fields object. Check list_fields first: not idempotent, and a duplicate name returns a validation error.',
+ idempotent: false,
+ },
+ outputSchema: fieldOutputSchema,
+ props: {
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'The field name.',
+ required: true,
+ }),
+ type: Property.StaticDropdown({
+ displayName: 'Type',
+ description: 'The field type. It cannot be changed later.',
+ required: true,
+ options: {
+ options: [
+ { label: 'Text', value: 'text' },
+ { label: 'Number', value: 'number' },
+ { label: 'Date', value: 'date' },
+ ],
+ },
+ }),
+ },
+ async run(context) {
+ const name = context.propsValue.name.trim();
+ if (!name) {
+ throw new Error('Name is required.');
+ }
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.POST,
+ path: '/fields',
+ body: { name, type: context.propsValue.type },
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/create-webhook.ts b/packages/pieces/community/mailer-lite/src/lib/actions/create-webhook.ts
new file mode 100644
index 000000000000..106d4fac1bed
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/create-webhook.ts
@@ -0,0 +1,76 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { webhookOutputSchema } from '../output-schemas';
+
+export const createWebhookAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'create_webhook',
+ classification: 'WRITE',
+ displayName: 'Create Webhook',
+ description: 'Create a webhook that posts MailerLite events to a URL.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Create a MailerLite webhook that POSTs the chosen events (for example subscriber.created) to a URL. The signing secret is never returned. Optionally set enabled and batchable. Not idempotent: each call creates another webhook, so check list_webhooks first.',
+ idempotent: false,
+ },
+ outputSchema: webhookOutputSchema,
+ props: {
+ url: Property.ShortText({
+ displayName: 'URL',
+ description: 'The HTTPS URL MailerLite will POST events to.',
+ required: true,
+ }),
+ events: Property.StaticMultiSelectDropdown({
+ displayName: 'Events',
+ description: 'The events that trigger the webhook.',
+ required: true,
+ options: { options: mailerLiteApi.webhookEventOptions() },
+ }),
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'A name for the webhook.',
+ required: false,
+ }),
+ enabled: Property.StaticDropdown({
+ displayName: 'Enabled',
+ description: 'Whether the webhook is active. MailerLite defaults to enabled.',
+ required: false,
+ options: { options: mailerLiteApi.booleanOptions() },
+ }),
+ batchable: Property.StaticDropdown({
+ displayName: 'Batchable',
+ description: 'Whether events are delivered in batches.',
+ required: false,
+ options: { options: mailerLiteApi.booleanOptions() },
+ }),
+ },
+ async run(context) {
+ const { url, events, name, enabled, batchable } = context.propsValue;
+ if (!url.trim()) {
+ throw new Error('URL is required.');
+ }
+ if (!events || events.length === 0) {
+ throw new Error('Select at least one event.');
+ }
+ const body: Record = { url: url.trim(), events };
+ if (name) {
+ body['name'] = name;
+ }
+ if (enabled === 'true' || enabled === 'false') {
+ body['enabled'] = enabled === 'true';
+ }
+ if (batchable === 'true' || batchable === 'false') {
+ body['batchable'] = batchable === 'true';
+ }
+ const response = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.POST,
+ path: '/webhooks',
+ body,
+ });
+ return mailerLiteApi.withoutSecret(mailerLiteApi.unwrapData(response));
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/delete-automation.ts b/packages/pieces/community/mailer-lite/src/lib/actions/delete-automation.ts
new file mode 100644
index 000000000000..8f9a07380e21
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/delete-automation.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { deleteAutomationOutputSchema } from '../output-schemas';
+
+export const deleteAutomationAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'delete_automation',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Automation',
+ description: 'Delete an automation.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Permanently delete a MailerLite automation by ID, including its steps; subscribers currently in it stop receiving its emails. Cannot be undone. Get the ID from list_automations. Not idempotent: a repeat call returns a 404 error.',
+ idempotent: false,
+ },
+ outputSchema: deleteAutomationOutputSchema,
+ props: {
+ automation_id: Property.ShortText({
+ displayName: 'Automation ID',
+ description: 'The automation ID, from list_automations.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.automation_id, label: 'Automation ID' });
+ await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.DELETE,
+ path: `/automations/${id}`,
+ resource: `automation ${id}`,
+ });
+ return { deleted: true, automation_id: context.propsValue.automation_id.trim() };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/delete-campaign.ts b/packages/pieces/community/mailer-lite/src/lib/actions/delete-campaign.ts
new file mode 100644
index 000000000000..a0a3b7e39e74
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/delete-campaign.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { deleteCampaignOutputSchema } from '../output-schemas';
+
+export const deleteCampaignAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'delete_campaign',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Campaign',
+ description: 'Delete a campaign.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Permanently delete a MailerLite campaign by ID, including its content and, for sent campaigns, its reports. Cannot be undone. Get the ID from list_campaigns. Not idempotent: a repeat call returns a 404 error.',
+ idempotent: false,
+ },
+ outputSchema: deleteCampaignOutputSchema,
+ props: {
+ campaign_id: Property.ShortText({
+ displayName: 'Campaign ID',
+ description: 'The campaign ID, from list_campaigns.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.campaign_id, label: 'Campaign ID' });
+ await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.DELETE,
+ path: `/campaigns/${id}`,
+ resource: `campaign ${id}`,
+ });
+ return { deleted: true, campaign_id: context.propsValue.campaign_id.trim() };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/delete-field.ts b/packages/pieces/community/mailer-lite/src/lib/actions/delete-field.ts
new file mode 100644
index 000000000000..6098f4dfe549
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/delete-field.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { deleteFieldOutputSchema } from '../output-schemas';
+
+export const deleteFieldAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'delete_field',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Field',
+ description: 'Delete a custom field.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Permanently delete a MailerLite custom field by ID. This also removes that field\'s value from every subscriber. Cannot be undone. Get the ID from list_fields. Not idempotent: a repeat call returns a 404 error.',
+ idempotent: false,
+ },
+ outputSchema: deleteFieldOutputSchema,
+ props: {
+ field_id: Property.ShortText({
+ displayName: 'Field ID',
+ description: 'The custom field ID, from list_fields.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.field_id, label: 'Field ID' });
+ await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.DELETE,
+ path: `/fields/${id}`,
+ resource: `field ${id}`,
+ });
+ return { deleted: true, field_id: context.propsValue.field_id.trim() };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/delete-group.ts b/packages/pieces/community/mailer-lite/src/lib/actions/delete-group.ts
new file mode 100644
index 000000000000..966f1fca7f48
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/delete-group.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { deleteGroupOutputSchema } from '../output-schemas';
+
+export const deleteGroupAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'delete_group',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Group',
+ description: 'Delete a subscriber group.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Permanently delete a MailerLite group by ID. The group membership is removed but the subscribers themselves are kept. Cannot be undone. Get the ID from list_groups or find_groups_by_name. Not idempotent: deleting an already-deleted group returns a 404 error.',
+ idempotent: false,
+ },
+ outputSchema: deleteGroupOutputSchema,
+ props: {
+ group_id: Property.ShortText({
+ displayName: 'Group ID',
+ description: 'The group ID, from list_groups or find_groups_by_name.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.group_id, label: 'Group ID' });
+ await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.DELETE,
+ path: `/groups/${id}`,
+ resource: `group ${id}`,
+ });
+ return { deleted: true, group_id: context.propsValue.group_id.trim() };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/delete-segment.ts b/packages/pieces/community/mailer-lite/src/lib/actions/delete-segment.ts
new file mode 100644
index 000000000000..3370838f5d21
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/delete-segment.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { deleteSegmentOutputSchema } from '../output-schemas';
+
+export const deleteSegmentAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'delete_segment',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Segment',
+ description: 'Delete a segment.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Permanently delete a MailerLite segment by ID. Only the segment definition is removed; the subscribers it matched are kept. Cannot be undone, and segments cannot be recreated through the API. Get the ID from list_segments. Not idempotent: a repeat call returns a 404 error.',
+ idempotent: false,
+ },
+ outputSchema: deleteSegmentOutputSchema,
+ props: {
+ segment_id: Property.ShortText({
+ displayName: 'Segment ID',
+ description: 'The segment ID, from list_segments.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.segment_id, label: 'Segment ID' });
+ await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.DELETE,
+ path: `/segments/${id}`,
+ resource: `segment ${id}`,
+ });
+ return { deleted: true, segment_id: context.propsValue.segment_id.trim() };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/delete-subscriber.ts b/packages/pieces/community/mailer-lite/src/lib/actions/delete-subscriber.ts
index f61f689f63ce..a176b435a587 100644
--- a/packages/pieces/community/mailer-lite/src/lib/actions/delete-subscriber.ts
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/delete-subscriber.ts
@@ -2,19 +2,20 @@ import MailerLite from '@mailerlite/mailerlite-nodejs';
import { createAction } from '@activepieces/pieces-framework';
import { mailerLiteAuth } from '../auth';
import { mailerLiteCommon } from '../common';
+import { mailerLiteApi } from '../common/client';
import { deleteSubscriberOutputSchema } from '../output-schemas';
export const deleteSubscriberAction = createAction({
auth: mailerLiteAuth,
name: 'delete_subscriber',
- classification: 'WRITE',
+ classification: 'DESTRUCTIVE',
displayName: 'Delete Subscriber',
- description: 'Permanently delete a subscriber.',
+ description: 'Delete a subscriber. MailerLite keeps their data in case they re-subscribe.',
audience: 'both',
aiMetadata: {
description:
- 'Permanently delete a MailerLite subscriber by ID, removing the record and its history. This cannot be undone; prefer Unsubscribe Subscriber for ordinary opt-outs. Returns no content. Idempotent in effect: deleting an already-deleted subscriber leaves the account in the same state.',
- idempotent: true,
+ 'Delete a MailerLite subscriber by ID. MailerLite keeps their data in case they re-subscribe; use forget_subscriber to permanently erase it (GDPR), or unsubscribe_subscriber for an ordinary opt-out. Not idempotent: a repeat call on a deleted subscriber returns a 404 error.',
+ idempotent: false,
},
outputSchema: deleteSubscriberOutputSchema,
props: {
@@ -22,7 +23,9 @@ export const deleteSubscriberAction = createAction({
},
async run(context) {
const client = new MailerLite({ api_key: context.auth.secret_text });
- await client.subscribers.delete(context.propsValue.subscriberId!);
+ await client.subscribers.delete(context.propsValue.subscriberId!).catch((error: unknown) => {
+ throw mailerLiteApi.readableSdkError({ error, resource: `subscriber ${context.propsValue.subscriberId}` });
+ });
return { deleted: true, subscriberId: context.propsValue.subscriberId };
},
});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/delete-webhook.ts b/packages/pieces/community/mailer-lite/src/lib/actions/delete-webhook.ts
new file mode 100644
index 000000000000..3dc682c85264
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/delete-webhook.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { deleteWebhookOutputSchema } from '../output-schemas';
+
+export const deleteWebhookAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'delete_webhook',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Webhook',
+ description: 'Delete a webhook.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Permanently delete a MailerLite webhook by ID. Warning: webhooks created by Activepieces MailerLite triggers are listed too, and deleting one silently breaks that flow. Get the ID from list_webhooks. Not idempotent: a repeat call returns a 404 error.',
+ idempotent: false,
+ },
+ outputSchema: deleteWebhookOutputSchema,
+ props: {
+ webhook_id: Property.ShortText({
+ displayName: 'Webhook ID',
+ description: 'The webhook ID, from list_webhooks.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.webhook_id, label: 'Webhook ID' });
+ await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.DELETE,
+ path: `/webhooks/${id}`,
+ resource: `webhook ${id}`,
+ });
+ return { deleted: true, webhook_id: context.propsValue.webhook_id.trim() };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/find-groups-by-name.ts b/packages/pieces/community/mailer-lite/src/lib/actions/find-groups-by-name.ts
new file mode 100644
index 000000000000..4496b6078a10
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/find-groups-by-name.ts
@@ -0,0 +1,71 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { findGroupsByNameOutputSchema } from '../output-schemas';
+
+export const findGroupsByNameAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'find_groups_by_name',
+ classification: 'SEARCH',
+ displayName: 'Find Groups by Name',
+ description: 'Find subscriber groups whose name contains a text.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Search MailerLite groups by name (partial, case-insensitive match) and return the matching groups with their IDs and subscriber counts. Use it to resolve a group name to the group ID other actions need. Page-paginated: use meta.last_page and pass page to get more. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: findGroupsByNameOutputSchema,
+ props: {
+ name: Property.ShortText({
+ displayName: 'Name Contains',
+ description: 'Text the group name must contain.',
+ required: true,
+ }),
+ sort: Property.StaticDropdown({
+ displayName: 'Sort',
+ description: 'Sort order; a minus prefix means descending.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Name', value: 'name' },
+ { label: 'Name (desc)', value: '-name' },
+ { label: 'Total', value: 'total' },
+ { label: 'Total (desc)', value: '-total' },
+ { label: 'Open rate', value: 'open_rate' },
+ { label: 'Open rate (desc)', value: '-open_rate' },
+ { label: 'Click rate', value: 'click_rate' },
+ { label: 'Click rate (desc)', value: '-click_rate' },
+ { label: 'Created at', value: 'created_at' },
+ { label: 'Created at (desc)', value: '-created_at' },
+ ],
+ },
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-1000, default 25).',
+ required: false,
+ defaultValue: 25,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/groups',
+ queryParams: {
+ 'filter[name]': context.propsValue.name,
+ 'sort': context.propsValue.sort,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 25, max: 1000 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/forget-subscriber.ts b/packages/pieces/community/mailer-lite/src/lib/actions/forget-subscriber.ts
new file mode 100644
index 000000000000..58feba3322ea
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/forget-subscriber.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { forgetSubscriberOutputSchema } from '../output-schemas';
+
+export const forgetSubscriberAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'forget_subscriber',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Forget Subscriber (GDPR)',
+ description: 'Remove a subscriber and permanently erase their data (GDPR).',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Remove a MailerLite subscriber and permanently erase all of their data within 30 days (GDPR right to be forgotten). This cannot be undone. Lighter options: delete_subscriber removes the subscriber but keeps their data in case they re-subscribe, and unsubscribe_subscriber only opts them out. Get the ID from find_subscriber. Returns the subscriber with forget_at set. Not idempotent: a repeat call on a forgotten subscriber returns an error.',
+ idempotent: false,
+ },
+ outputSchema: forgetSubscriberOutputSchema,
+ props: {
+ subscriber_id: Property.ShortText({
+ displayName: 'Subscriber ID',
+ description: 'The subscriber ID, from find_subscriber or list_subscribers.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.subscriber_id, label: 'Subscriber ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.POST,
+ path: `/subscribers/${id}/forget`,
+ resource: `subscriber ${id}`,
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/get-automation-activity.ts b/packages/pieces/community/mailer-lite/src/lib/actions/get-automation-activity.ts
new file mode 100644
index 000000000000..744fb9b9be2b
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/get-automation-activity.ts
@@ -0,0 +1,96 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { automationActivityOutputSchema } from '../output-schemas';
+
+export const getAutomationActivityAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'get_automation_activity',
+ classification: 'SEARCH',
+ displayName: 'Get Automation Activity',
+ description: 'List subscriber activity in an automation.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List the subscribers who went through a MailerLite automation, given the automation ID from list_automations. status is required: completed, active, canceled or failed. Date filters use YYYY-MM-DD: date_from/date_to apply to finished runs, scheduled_from/scheduled_to to active runs. Page-paginated. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: automationActivityOutputSchema,
+ props: {
+ automation_id: Property.ShortText({
+ displayName: 'Automation ID',
+ description: 'The automation ID, from list_automations.',
+ required: true,
+ }),
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Which activity to return.',
+ required: true,
+ options: {
+ options: [
+ { label: 'Completed', value: 'completed' },
+ { label: 'Active', value: 'active' },
+ { label: 'Canceled', value: 'canceled' },
+ { label: 'Failed', value: 'failed' },
+ ],
+ },
+ }),
+ date_from: Property.ShortText({
+ displayName: 'Date From',
+ description: 'Finished on or after this date (YYYY-MM-DD).',
+ required: false,
+ }),
+ date_to: Property.ShortText({
+ displayName: 'Date To',
+ description: 'Finished on or before this date (YYYY-MM-DD).',
+ required: false,
+ }),
+ scheduled_from: Property.ShortText({
+ displayName: 'Scheduled From',
+ description: 'Active runs scheduled on or after this date (YYYY-MM-DD).',
+ required: false,
+ }),
+ scheduled_to: Property.ShortText({
+ displayName: 'Scheduled To',
+ description: 'Active runs scheduled on or before this date (YYYY-MM-DD).',
+ required: false,
+ }),
+ search: Property.ShortText({
+ displayName: 'Subscriber Email',
+ description: 'Only return activity for subscribers whose email matches this text.',
+ required: false,
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-100, default 10).',
+ required: false,
+ defaultValue: 10,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.automation_id, label: 'Automation ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/automations/${id}/activity`,
+ resource: `automation ${id}`,
+ queryParams: {
+ 'filter[status]': context.propsValue.status,
+ 'filter[date_from]': context.propsValue.date_from,
+ 'filter[date_to]': context.propsValue.date_to,
+ 'filter[scheduled_from]': context.propsValue.scheduled_from,
+ 'filter[scheduled_to]': context.propsValue.scheduled_to,
+ 'filter[search]': context.propsValue.search,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 10, max: 100 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/get-automation.ts b/packages/pieces/community/mailer-lite/src/lib/actions/get-automation.ts
new file mode 100644
index 000000000000..f3879187dedf
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/get-automation.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { automationOutputSchema } from '../output-schemas';
+
+export const getAutomationAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'get_automation',
+ classification: 'READ',
+ displayName: 'Get Automation',
+ description: 'Get an automation by ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Get one MailerLite automation by ID, including its enabled state, trigger configuration, steps and stats. Get the ID from list_automations. Read-only; returns a 404 error if the ID does not exist.',
+ idempotent: true,
+ },
+ outputSchema: automationOutputSchema,
+ props: {
+ automation_id: Property.ShortText({
+ displayName: 'Automation ID',
+ description: 'The automation ID, from list_automations.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.automation_id, label: 'Automation ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/automations/${id}`,
+ resource: `automation ${id}`,
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/get-campaign.ts b/packages/pieces/community/mailer-lite/src/lib/actions/get-campaign.ts
new file mode 100644
index 000000000000..88482dae630d
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/get-campaign.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { campaignOutputSchema } from '../output-schemas';
+
+export const getCampaignAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'get_campaign',
+ classification: 'READ',
+ displayName: 'Get Campaign',
+ description: 'Get a campaign by ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Get one MailerLite campaign by ID, including status (draft, ready, sent), audience, emails and stats. Get the ID from list_campaigns. Read-only; returns a 404 error if the ID does not exist.',
+ idempotent: true,
+ },
+ outputSchema: campaignOutputSchema,
+ props: {
+ campaign_id: Property.ShortText({
+ displayName: 'Campaign ID',
+ description: 'The campaign ID, from list_campaigns.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.campaign_id, label: 'Campaign ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/campaigns/${id}`,
+ resource: `campaign ${id}`,
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/get-form.ts b/packages/pieces/community/mailer-lite/src/lib/actions/get-form.ts
new file mode 100644
index 000000000000..88f89fa44fbe
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/get-form.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { formOutputSchema } from '../output-schemas';
+
+export const getFormAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'get_form',
+ classification: 'READ',
+ displayName: 'Get Form',
+ description: 'Get a form by ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Get one MailerLite signup form (popup, embedded or promotion) by ID, including conversions and settings. Get the ID from list_forms. Read-only; returns a 404 error if the ID does not exist.',
+ idempotent: true,
+ },
+ outputSchema: formOutputSchema,
+ props: {
+ form_id: Property.ShortText({
+ displayName: 'Form ID',
+ description: 'The form ID, from list_forms.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.form_id, label: 'Form ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/forms/${id}`,
+ resource: `form ${id}`,
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/get-import-status.ts b/packages/pieces/community/mailer-lite/src/lib/actions/get-import-status.ts
new file mode 100644
index 000000000000..90c0a5228944
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/get-import-status.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { importStatusOutputSchema } from '../output-schemas';
+
+export const getImportStatusAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'get_import_status',
+ classification: 'READ',
+ displayName: 'Get Import Status',
+ description: 'Get the progress and report of a subscriber import.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Check the progress of a subscriber import started by import_subscribers_to_group, given its import_id. Makes one read and does not wait: if done is false, call it again later. When done, the report counts imported, updated and errored rows. Read-only and idempotent.',
+ idempotent: true,
+ },
+ outputSchema: importStatusOutputSchema,
+ props: {
+ import_id: Property.ShortText({
+ displayName: 'Import ID',
+ description: 'The import ID returned by import_subscribers_to_group.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.import_id, label: 'Import ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/subscribers/import/${id}`,
+ resource: `import ${id}`,
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/get-subscriber-activity.ts b/packages/pieces/community/mailer-lite/src/lib/actions/get-subscriber-activity.ts
new file mode 100644
index 000000000000..79db2cbe6cca
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/get-subscriber-activity.ts
@@ -0,0 +1,72 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { subscriberActivityOutputSchema } from '../output-schemas';
+
+export const getSubscriberActivityAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'get_subscriber_activity',
+ classification: 'SEARCH',
+ displayName: 'Get Subscriber Activity',
+ description: 'List the activity log of a subscriber.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List the activity log of a MailerLite subscriber (campaign sends, opens, clicks, bounces, unsubscribes and more), given the subscriber ID from find_subscriber. Optionally filter by one log type. Page-paginated. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: subscriberActivityOutputSchema,
+ props: {
+ subscriber_id: Property.ShortText({
+ displayName: 'Subscriber ID',
+ description: 'The subscriber ID, from find_subscriber or list_subscribers.',
+ required: true,
+ }),
+ log_name: Property.StaticDropdown({
+ displayName: 'Activity Type',
+ description: 'Only return this type of activity.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Campaign send', value: 'campaign_send' },
+ { label: 'Automation email sent', value: 'automation_email_sent' },
+ { label: 'Email open', value: 'email_open' },
+ { label: 'Link click', value: 'link_click' },
+ { label: 'Email bounce', value: 'email_bounce' },
+ { label: 'Spam complaint', value: 'spam_complaint' },
+ { label: 'Unsubscribed', value: 'unsubscribed' },
+ { label: 'Email forward', value: 'email_forward' },
+ { label: 'Marketing preferences change', value: 'marketing_preferences_change' },
+ { label: 'Preference center', value: 'preference_center' },
+ ],
+ },
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-100, default 100).',
+ required: false,
+ defaultValue: 100,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.subscriber_id, label: 'Subscriber ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/subscribers/${id}/activity-log`,
+ resource: `subscriber ${id}`,
+ queryParams: {
+ 'filter[log_name]': context.propsValue.log_name,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 100, max: 100 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/get-webhook.ts b/packages/pieces/community/mailer-lite/src/lib/actions/get-webhook.ts
new file mode 100644
index 000000000000..969d2935aad4
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/get-webhook.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { webhookOutputSchema } from '../output-schemas';
+
+export const getWebhookAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'get_webhook',
+ classification: 'READ',
+ displayName: 'Get Webhook',
+ description: 'Get a webhook by ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Get one MailerLite webhook by ID: name, URL, subscribed events, enabled and batchable flags. The signing secret is never returned. Get the ID from list_webhooks. Read-only; returns a 404 error if the ID does not exist.',
+ idempotent: true,
+ },
+ outputSchema: webhookOutputSchema,
+ props: {
+ webhook_id: Property.ShortText({
+ displayName: 'Webhook ID',
+ description: 'The webhook ID, from list_webhooks.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.webhook_id, label: 'Webhook ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/webhooks/${id}`,
+ resource: `webhook ${id}`,
+ });
+ return mailerLiteApi.withoutSecret(mailerLiteApi.unwrapData(body));
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/import-subscribers-to-group.ts b/packages/pieces/community/mailer-lite/src/lib/actions/import-subscribers-to-group.ts
new file mode 100644
index 000000000000..ad562fac6ca1
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/import-subscribers-to-group.ts
@@ -0,0 +1,56 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { importSubscribersOutputSchema } from '../output-schemas';
+
+export const importSubscribersToGroupAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'import_subscribers_to_group',
+ classification: 'WRITE',
+ displayName: 'Import Subscribers to Group',
+ description: 'Start a bulk import of subscribers into a group.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Start a bulk import of subscribers into a MailerLite group. subscribers is a JSON array of objects, each with an email and an optional fields object. Existing subscribers are updated and added to the group. The import runs in the background and this action does not wait: it returns an import_id to pass to get_import_status to check progress. Limited to 5 imports per minute. Not idempotent: each call starts a new import.',
+ idempotent: false,
+ },
+ outputSchema: importSubscribersOutputSchema,
+ props: {
+ group_id: Property.ShortText({
+ displayName: 'Group ID',
+ description: 'The group ID, from list_groups or find_groups_by_name.',
+ required: true,
+ }),
+ subscribers: Property.Json({
+ displayName: 'Subscribers',
+ description: 'JSON array of subscribers, e.g. [{"email": "a@example.com", "fields": {"name": "Ann"}}].',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.group_id, label: 'Group ID' });
+ const input: unknown = context.propsValue.subscribers;
+ if (!Array.isArray(input) || input.length === 0) {
+ throw new Error('Subscribers must be a non-empty JSON array.');
+ }
+ const subscribers = input.map((row: unknown, index: number) => {
+ if (!mailerLiteApi.isRecord(row) || typeof row['email'] !== 'string' || !row['email'].trim()) {
+ throw new Error(`Subscriber at position ${index + 1} must have an email.`);
+ }
+ const fields = row['fields'];
+ return mailerLiteApi.isRecord(fields) ? { email: row['email'].trim(), fields } : { email: row['email'].trim() };
+ });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.POST,
+ path: `/groups/${id}/import-subscribers`,
+ resource: `group ${id}`,
+ body: { subscribers },
+ });
+ const url = mailerLiteApi.isRecord(body) && typeof body['import_progress_url'] === 'string' ? body['import_progress_url'] : '';
+ const importId = url.split('/').filter((segment) => segment.length > 0).pop() ?? '';
+ return { import_id: importId, import_progress_url: url };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-automations.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-automations.ts
new file mode 100644
index 000000000000..0e477d77dd1f
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-automations.ts
@@ -0,0 +1,69 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listAutomationsOutputSchema } from '../output-schemas';
+
+export const listAutomationsAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_automations',
+ classification: 'SEARCH',
+ displayName: 'List Automations',
+ description: 'List the automations in the account.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List MailerLite automations with their IDs, enabled state and stats. Optionally filter by enabled state, name, or a group ID from list_groups. Page-paginated. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listAutomationsOutputSchema,
+ props: {
+ enabled: Property.StaticDropdown({
+ displayName: 'Enabled',
+ description: 'Only return enabled or disabled automations.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Enabled', value: 'true' },
+ { label: 'Disabled', value: 'false' },
+ ],
+ },
+ }),
+ name: Property.ShortText({
+ displayName: 'Name Contains',
+ description: 'Only return automations whose name contains this text.',
+ required: false,
+ }),
+ group_id: Property.ShortText({
+ displayName: 'Group ID',
+ description: 'Only return automations that use this group, from list_groups.',
+ required: false,
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-100, default 10).',
+ required: false,
+ defaultValue: 10,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/automations',
+ queryParams: {
+ 'filter[enabled]': context.propsValue.enabled,
+ 'filter[name]': context.propsValue.name,
+ 'filter[group]': context.propsValue.group_id,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 10, max: 100 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-campaign-languages.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-campaign-languages.ts
new file mode 100644
index 000000000000..42d6c7d64c9f
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-campaign-languages.ts
@@ -0,0 +1,29 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listCampaignLanguagesOutputSchema } from '../output-schemas';
+
+export const listCampaignLanguagesAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_campaign_languages',
+ classification: 'READ',
+ displayName: 'List Campaign Languages',
+ description: 'List the languages available for campaigns.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List the languages MailerLite supports for campaigns, with their IDs. Pass an ID as language_id to create_campaign_draft or update_campaign_draft. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listCampaignLanguagesOutputSchema,
+ props: {},
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/campaigns/languages',
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-campaigns.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-campaigns.ts
new file mode 100644
index 000000000000..2fad65d04be2
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-campaigns.ts
@@ -0,0 +1,72 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listCampaignsOutputSchema } from '../output-schemas';
+
+export const listCampaignsAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_campaigns',
+ classification: 'SEARCH',
+ displayName: 'List Campaigns',
+ description: 'List campaigns, optionally filtered by status and type.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List MailerLite campaigns with their IDs, status and stats. MailerLite returns only ready (scheduled) campaigns unless status is set, so set status to draft or sent to see those. Page-paginated. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listCampaignsOutputSchema,
+ props: {
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Only return campaigns with this status. MailerLite defaults to ready.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Draft', value: 'draft' },
+ { label: 'Ready', value: 'ready' },
+ { label: 'Sent', value: 'sent' },
+ ],
+ },
+ }),
+ type: Property.StaticDropdown({
+ displayName: 'Type',
+ description: 'Only return campaigns of this type.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Regular', value: 'regular' },
+ { label: 'A/B split', value: 'ab' },
+ { label: 'Resend', value: 'resend' },
+ { label: 'RSS', value: 'rss' },
+ ],
+ },
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-100, default 25).',
+ required: false,
+ defaultValue: 25,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/campaigns',
+ queryParams: {
+ 'filter[status]': context.propsValue.status,
+ 'filter[type]': context.propsValue.type,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 25, max: 100 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-fields.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-fields.ts
new file mode 100644
index 000000000000..679cb780807a
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-fields.ts
@@ -0,0 +1,64 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listFieldsOutputSchema } from '../output-schemas';
+
+export const listFieldsAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_fields',
+ classification: 'SEARCH',
+ displayName: 'List Fields',
+ description: 'List the subscriber fields in the account.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List MailerLite subscriber fields (default and custom) with their IDs, keys and types. The key is what goes into a subscriber\'s fields object. Optionally filter by keyword or type. Page-paginated. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listFieldsOutputSchema,
+ props: {
+ keyword: Property.ShortText({
+ displayName: 'Keyword',
+ description: 'Only return fields whose name contains this text.',
+ required: false,
+ }),
+ type: Property.StaticDropdown({
+ displayName: 'Type',
+ description: 'Only return fields of this type.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Text', value: 'text' },
+ { label: 'Number', value: 'number' },
+ { label: 'Date', value: 'date' },
+ ],
+ },
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-100, default 100).',
+ required: false,
+ defaultValue: 100,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/fields',
+ queryParams: {
+ 'filter[keyword]': context.propsValue.keyword,
+ 'filter[type]': context.propsValue.type,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 100, max: 100 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-form-subscribers.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-form-subscribers.ts
new file mode 100644
index 000000000000..0a96ea2a8a5a
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-form-subscribers.ts
@@ -0,0 +1,67 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listFormSubscribersOutputSchema } from '../output-schemas';
+
+export const listFormSubscribersAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_form_subscribers',
+ classification: 'SEARCH',
+ displayName: 'List Form Subscribers',
+ description: 'List the subscribers who signed up through a form.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List the subscribers who signed up through a MailerLite form, given the form ID from list_forms. By default MailerLite returns active subscribers only; set status to list others. Cursor-paginated: pass meta.next_cursor back as cursor to get the next page. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listFormSubscribersOutputSchema,
+ props: {
+ form_id: Property.ShortText({
+ displayName: 'Form ID',
+ description: 'The form ID, from list_forms.',
+ required: true,
+ }),
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Only return subscribers with this status. MailerLite defaults to active.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Active', value: 'active' },
+ { label: 'Unsubscribed', value: 'unsubscribed' },
+ { label: 'Unconfirmed', value: 'unconfirmed' },
+ { label: 'Bounced', value: 'bounced' },
+ { label: 'Junk', value: 'junk' },
+ ],
+ },
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-1000, default 25).',
+ required: false,
+ defaultValue: 25,
+ }),
+ cursor: Property.ShortText({
+ displayName: 'Cursor',
+ description: 'The meta.next_cursor value from the previous page. Leave empty for the first page.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.form_id, label: 'Form ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/forms/${id}/subscribers`,
+ resource: `form ${id}`,
+ queryParams: {
+ 'filter[status]': context.propsValue.status,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 25, max: 1000 }),
+ cursor: context.propsValue.cursor,
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-forms.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-forms.ts
new file mode 100644
index 000000000000..e7851713ff57
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-forms.ts
@@ -0,0 +1,65 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listFormsOutputSchema } from '../output-schemas';
+
+export const listFormsAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_forms',
+ classification: 'SEARCH',
+ displayName: 'List Forms',
+ description: 'List signup forms of one type.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List MailerLite signup forms of one type (popup, embedded or promotion) with their IDs and conversion stats. Forms cannot be created through the API. Page-paginated. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listFormsOutputSchema,
+ props: {
+ type: Property.StaticDropdown({
+ displayName: 'Form Type',
+ description: 'The type of forms to list.',
+ required: true,
+ options: {
+ options: [
+ { label: 'Popup', value: 'popup' },
+ { label: 'Embedded', value: 'embedded' },
+ { label: 'Promotion', value: 'promotion' },
+ ],
+ },
+ }),
+ name: Property.ShortText({
+ displayName: 'Name Contains',
+ description: 'Only return forms whose name contains this text.',
+ required: false,
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-100, default 25).',
+ required: false,
+ defaultValue: 25,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.type, label: 'Form Type' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/forms/${id}`,
+ resource: `forms ${id}`,
+ queryParams: {
+ 'filter[name]': context.propsValue.name,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 25, max: 100 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-group-subscribers.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-group-subscribers.ts
index 1d109476060a..12a1fb068f68 100644
--- a/packages/pieces/community/mailer-lite/src/lib/actions/list-group-subscribers.ts
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-group-subscribers.ts
@@ -1,7 +1,8 @@
-import MailerLite from '@mailerlite/mailerlite-nodejs';
+import { HttpMethod } from '@activepieces/pieces-common';
import { Property, createAction } from '@activepieces/pieces-framework';
import { mailerLiteAuth } from '../auth';
import { mailerLiteCommon } from '../common';
+import { mailerLiteApi } from '../common/client';
import { listGroupSubscribersOutputSchema } from '../output-schemas';
export const listGroupSubscribersAction = createAction({
@@ -13,29 +14,50 @@ export const listGroupSubscribersAction = createAction({
audience: 'both',
aiMetadata: {
description:
- 'List the subscribers that belong to a specific MailerLite group, given the group ID. Use this to enumerate the members of a segment before messaging or reporting on them. Read-only and idempotent.',
+ 'List the subscribers that belong to a specific MailerLite group, given the group ID. By default MailerLite returns active subscribers only; set status to list unsubscribed, unconfirmed, bounced or junk members instead. Cursor-paginated: pass meta.next_cursor back as cursor to get the next page. Read-only and idempotent.',
idempotent: true,
},
outputSchema: listGroupSubscribersOutputSchema,
props: {
subscriberGroupId: mailerLiteCommon.subscriberGroupId(true),
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Only return subscribers with this status. MailerLite defaults to active.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Active', value: 'active' },
+ { label: 'Unsubscribed', value: 'unsubscribed' },
+ { label: 'Unconfirmed', value: 'unconfirmed' },
+ { label: 'Bounced', value: 'bounced' },
+ { label: 'Junk', value: 'junk' },
+ ],
+ },
+ }),
limit: Property.Number({
displayName: 'Limit',
description: 'Subscribers to return (1-1000, default 25).',
required: false,
defaultValue: 25,
}),
+ cursor: Property.ShortText({
+ displayName: 'Cursor',
+ description: 'The meta.next_cursor value from the previous page. Leave empty for the first page.',
+ required: false,
+ }),
},
async run(context) {
- const resolvedLimit = Math.trunc(Number(context.propsValue.limit ?? 25));
- if (!Number.isFinite(resolvedLimit) || resolvedLimit < 1 || resolvedLimit > 1000) {
- throw new Error('Limit must be between 1 and 1000.');
- }
- const client = new MailerLite({ api_key: context.auth.secret_text });
- const response = await client.groups.getSubscribers(context.propsValue.subscriberGroupId!, {
- limit: resolvedLimit,
- page: 1,
+ const id = mailerLiteApi.requireId({ value: context.propsValue.subscriberGroupId, label: 'Group' });
+ return mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/groups/${id}/subscribers`,
+ resource: `group ${id}`,
+ queryParams: {
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 25, max: 1000 }),
+ cursor: context.propsValue.cursor,
+ 'filter[status]': context.propsValue.status,
+ },
});
- return response.data;
},
});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-segment-subscribers.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-segment-subscribers.ts
new file mode 100644
index 000000000000..e92e15f28290
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-segment-subscribers.ts
@@ -0,0 +1,67 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listSegmentSubscribersOutputSchema } from '../output-schemas';
+
+export const listSegmentSubscribersAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_segment_subscribers',
+ classification: 'SEARCH',
+ displayName: 'List Segment Subscribers',
+ description: 'List the subscribers that match a segment.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List the subscribers that match a MailerLite segment, given the segment ID from list_segments. By default MailerLite returns active subscribers only; set status to list unsubscribed, unconfirmed, bounced or junk ones instead. Cursor-paginated: pass meta.next_cursor back as cursor to get the next page. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listSegmentSubscribersOutputSchema,
+ props: {
+ segment_id: Property.ShortText({
+ displayName: 'Segment ID',
+ description: 'The segment ID, from list_segments.',
+ required: true,
+ }),
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Only return subscribers with this status. MailerLite defaults to active.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Active', value: 'active' },
+ { label: 'Unsubscribed', value: 'unsubscribed' },
+ { label: 'Unconfirmed', value: 'unconfirmed' },
+ { label: 'Bounced', value: 'bounced' },
+ { label: 'Junk', value: 'junk' },
+ ],
+ },
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-1000, default 50).',
+ required: false,
+ defaultValue: 50,
+ }),
+ cursor: Property.ShortText({
+ displayName: 'Cursor',
+ description: 'The meta.next_cursor value from the previous page. Leave empty for the first page.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.segment_id, label: 'Segment ID' });
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/segments/${id}/subscribers`,
+ resource: `segment ${id}`,
+ queryParams: {
+ 'filter[status]': context.propsValue.status,
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 50, max: 1000 }),
+ cursor: context.propsValue.cursor,
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-segments.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-segments.ts
new file mode 100644
index 000000000000..9e0d12f656e5
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-segments.ts
@@ -0,0 +1,45 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listSegmentsOutputSchema } from '../output-schemas';
+
+export const listSegmentsAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_segments',
+ classification: 'SEARCH',
+ displayName: 'List Segments',
+ description: 'List the segments in the account.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List MailerLite segments (saved subscriber filters created in the dashboard) with their IDs and subscriber totals. Segments cannot be created through the API. Page-paginated: pass page for more. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listSegmentsOutputSchema,
+ props: {
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Items to return (1-250, default 50).',
+ required: false,
+ defaultValue: 50,
+ }),
+ page: Property.Number({
+ displayName: 'Page',
+ description: 'Page number to return, starting at 1.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/segments',
+ queryParams: {
+ limit: mailerLiteApi.resolveLimit({ value: context.propsValue.limit, fallback: 50, max: 250 }),
+ page: mailerLiteApi.resolvePage(context.propsValue.page),
+ },
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-subscribers.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-subscribers.ts
index bca1644db971..70d657e93152 100644
--- a/packages/pieces/community/mailer-lite/src/lib/actions/list-subscribers.ts
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-subscribers.ts
@@ -12,7 +12,7 @@ export const listSubscribersAction = createAction({
audience: 'both',
aiMetadata: {
description:
- 'List MailerLite subscribers, optionally filtered by status (active, unsubscribed, unconfirmed, bounced, junk). Use this to enumerate contacts for reporting or bulk processing. Returns a page of subscriber records with cursor pagination. Read-only and idempotent.',
+ 'List MailerLite subscribers, optionally filtered by status (active, unsubscribed, unconfirmed, bounced, junk). Use this to enumerate contacts for reporting or bulk processing. Returns a page of subscriber records with cursor pagination: pass meta.next_cursor back as cursor to get the next page. Read-only and idempotent.',
idempotent: true,
},
outputSchema: listSubscribersOutputSchema,
@@ -37,10 +37,15 @@ export const listSubscribersAction = createAction({
required: false,
defaultValue: 25,
}),
+ cursor: Property.ShortText({
+ displayName: 'Cursor',
+ description: 'The meta.next_cursor value from the previous page. Leave empty for the first page.',
+ required: false,
+ }),
},
async run(context) {
const client = new MailerLite({ api_key: context.auth.secret_text });
- const { status, limit } = context.propsValue;
+ const { status, limit, cursor } = context.propsValue;
const resolvedLimit = Math.trunc(Number(limit ?? 25));
if (!Number.isFinite(resolvedLimit) || resolvedLimit < 1 || resolvedLimit > 1000) {
throw new Error('Limit must be between 1 and 1000.');
@@ -53,8 +58,8 @@ export const listSubscribersAction = createAction({
status === 'junk';
const response = await client.subscribers.get(
isKnownStatus
- ? { limit: resolvedLimit, filter: { status } }
- : { limit: resolvedLimit },
+ ? { limit: resolvedLimit, cursor: cursor || undefined, filter: { status } }
+ : { limit: resolvedLimit, cursor: cursor || undefined },
);
return response.data;
},
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-timezones.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-timezones.ts
new file mode 100644
index 000000000000..436abbc894f4
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-timezones.ts
@@ -0,0 +1,29 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listTimezonesOutputSchema } from '../output-schemas';
+
+export const listTimezonesAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_timezones',
+ classification: 'READ',
+ displayName: 'List Timezones',
+ description: 'List the timezones MailerLite supports.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List the timezones MailerLite supports, with their IDs, names and UTC offsets. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listTimezonesOutputSchema,
+ props: {},
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/timezones',
+ });
+ return body;
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/list-webhooks.ts b/packages/pieces/community/mailer-lite/src/lib/actions/list-webhooks.ts
new file mode 100644
index 000000000000..160d3f106385
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/list-webhooks.ts
@@ -0,0 +1,30 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { listWebhooksOutputSchema } from '../output-schemas';
+
+export const listWebhooksAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'list_webhooks',
+ classification: 'SEARCH',
+ displayName: 'List Webhooks',
+ description: 'List the webhooks in the account.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'List the MailerLite webhooks in the account with their IDs, URLs, events and enabled state. Signing secrets are never returned. Webhooks created by Activepieces MailerLite triggers appear here too. Read-only.',
+ idempotent: true,
+ },
+ outputSchema: listWebhooksOutputSchema,
+ props: {},
+ async run(context) {
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: '/webhooks',
+ });
+ const items = mailerLiteApi.isRecord(body) && Array.isArray(body['data']) ? body['data'] : [];
+ return { ...(mailerLiteApi.isRecord(body) ? body : {}), data: items.map((item) => mailerLiteApi.withoutSecret(item)) };
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/remove-subscriber-from-group.ts b/packages/pieces/community/mailer-lite/src/lib/actions/remove-subscriber-from-group.ts
index fc53ebada602..5686e36c3bd3 100644
--- a/packages/pieces/community/mailer-lite/src/lib/actions/remove-subscriber-from-group.ts
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/remove-subscriber-from-group.ts
@@ -12,7 +12,7 @@ export const removeSubscriberFromGroupAction = createAction({
audience: 'both',
aiMetadata: {
description:
- 'Unassign a MailerLite subscriber from a group, given the subscriber ID and group ID. Use this to remove a contact from a specific list/group without deleting the subscriber. Idempotent — re-running for a subscriber not in the group leaves membership unchanged.',
+ 'Unassign a MailerLite subscriber from a group, given the subscriber ID and group ID. Use this to remove a contact from a specific list/group without deleting the subscriber. Idempotent: removing a subscriber who is no longer in the group succeeds and leaves membership unchanged.',
idempotent: true,
},
props: {
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/rename-field.ts b/packages/pieces/community/mailer-lite/src/lib/actions/rename-field.ts
new file mode 100644
index 000000000000..e8e5decf8514
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/rename-field.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { fieldOutputSchema } from '../output-schemas';
+
+export const renameFieldAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'rename_field',
+ classification: 'WRITE',
+ displayName: 'Rename Field',
+ description: 'Rename a custom field.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Rename a MailerLite custom field by ID. The field type cannot be changed and subscriber values are kept. Get the ID from list_fields. Idempotent.',
+ idempotent: true,
+ },
+ outputSchema: fieldOutputSchema,
+ props: {
+ field_id: Property.ShortText({
+ displayName: 'Field ID',
+ description: 'The custom field ID, from list_fields.',
+ required: true,
+ }),
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'The new field name.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.field_id, label: 'Field ID' });
+ const name = context.propsValue.name.trim();
+ if (!name) {
+ throw new Error('Name is required.');
+ }
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.PUT,
+ path: `/fields/${id}`,
+ resource: `field ${id}`,
+ body: { name },
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/rename-group.ts b/packages/pieces/community/mailer-lite/src/lib/actions/rename-group.ts
new file mode 100644
index 000000000000..38affdc06e92
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/rename-group.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { groupOutputSchema } from '../output-schemas';
+
+export const renameGroupAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'rename_group',
+ classification: 'WRITE',
+ displayName: 'Rename Group',
+ description: 'Rename a subscriber group.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Rename a MailerLite group by ID. Only the name changes; members are untouched. Get the ID from list_groups or find_groups_by_name. Idempotent: the same name twice leaves the same state.',
+ idempotent: true,
+ },
+ outputSchema: groupOutputSchema,
+ props: {
+ group_id: Property.ShortText({
+ displayName: 'Group ID',
+ description: 'The group ID, from list_groups or find_groups_by_name.',
+ required: true,
+ }),
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'The new group name (max 255 characters).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.group_id, label: 'Group ID' });
+ const name = context.propsValue.name.trim();
+ if (!name) {
+ throw new Error('Name is required.');
+ }
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.PUT,
+ path: `/groups/${id}`,
+ resource: `group ${id}`,
+ body: { name },
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/rename-segment.ts b/packages/pieces/community/mailer-lite/src/lib/actions/rename-segment.ts
new file mode 100644
index 000000000000..88a3ca512ca6
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/rename-segment.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { segmentOutputSchema } from '../output-schemas';
+
+export const renameSegmentAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'rename_segment',
+ classification: 'WRITE',
+ displayName: 'Rename Segment',
+ description: 'Rename a segment.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Rename a MailerLite segment by ID. Only the name changes; the segment rules are untouched. Get the ID from list_segments. Idempotent.',
+ idempotent: true,
+ },
+ outputSchema: segmentOutputSchema,
+ props: {
+ segment_id: Property.ShortText({
+ displayName: 'Segment ID',
+ description: 'The segment ID, from list_segments.',
+ required: true,
+ }),
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'The new segment name (max 255 characters).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.segment_id, label: 'Segment ID' });
+ const name = context.propsValue.name.trim();
+ if (!name) {
+ throw new Error('Name is required.');
+ }
+ const body = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.PUT,
+ path: `/segments/${id}`,
+ resource: `segment ${id}`,
+ body: { name },
+ });
+ return mailerLiteApi.unwrapData(body);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/unsubscribe-subscriber.ts b/packages/pieces/community/mailer-lite/src/lib/actions/unsubscribe-subscriber.ts
index 7f4e6f4a70a3..1dd7d0128b1f 100644
--- a/packages/pieces/community/mailer-lite/src/lib/actions/unsubscribe-subscriber.ts
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/unsubscribe-subscriber.ts
@@ -6,7 +6,7 @@ import { unsubscribeSubscriberOutputSchema } from '../output-schemas';
export const unsubscribeSubscriberAction = createAction({
auth: mailerLiteAuth,
name: 'unsubscribe_subscriber',
- classification: 'WRITE',
+ classification: 'DESTRUCTIVE',
displayName: 'Unsubscribe Subscriber',
description: 'Set a subscriber status to unsubscribed.',
audience: 'both',
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/update-campaign-draft.ts b/packages/pieces/community/mailer-lite/src/lib/actions/update-campaign-draft.ts
new file mode 100644
index 000000000000..e0f414c4b0e0
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/update-campaign-draft.ts
@@ -0,0 +1,215 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { campaignWriteOutputSchema } from '../output-schemas';
+
+function idsOf(value: unknown): string[] {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+ return value
+ .map((item) => (mailerLiteApi.isRecord(item) ? item['id'] : item))
+ .filter((item) => typeof item === 'string' && item !== '')
+ .map(String);
+}
+
+function inclusionOf(rule: unknown): { kind: 'groups' | 'segments'; ids: string[] } | null {
+ if (!mailerLiteApi.isRecord(rule) || rule['operator'] !== 'in_any' || !Array.isArray(rule['args'])) {
+ return null;
+ }
+ const [target, values] = rule['args'];
+ if ((target !== 'groups' && target !== 'segments') || !Array.isArray(values)) {
+ return null;
+ }
+ return { kind: target, ids: values.map(String) };
+}
+
+function currentTargeting(current: Record): Targeting {
+ const filter = current['filter'];
+ if (!Array.isArray(filter) || filter.length === 0) {
+ return { groups: idsOf(current['groups']), segments: idsOf(current['segments']), representable: true };
+ }
+ const rules = filter.flatMap((clause: unknown) => (Array.isArray(clause) ? clause : [clause]));
+ const inclusions = rules.map(inclusionOf);
+ const idsFor = (kind: 'groups' | 'segments'): string[] =>
+ Array.from(new Set(inclusions.flatMap((inclusion) => (inclusion?.kind === kind ? inclusion.ids : []))));
+ return {
+ groups: idsFor('groups'),
+ segments: idsFor('segments'),
+ representable: inclusions.every((inclusion) => inclusion !== null),
+ };
+}
+
+function toIdList(value: unknown): string[] | undefined {
+ if (!Array.isArray(value)) {
+ return undefined;
+ }
+ const ids = value.map((item) => String(item).trim()).filter((item) => item.length > 0);
+ return ids.length > 0 ? ids : undefined;
+}
+
+function textOf(value: unknown): string | undefined {
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
+}
+
+export const updateCampaignDraftAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'update_campaign_draft',
+ classification: 'WRITE',
+ displayName: 'Update Campaign Draft',
+ description: 'Update a draft campaign, keeping the values you do not change.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Update a MailerLite campaign that is still a draft, changing only the values you supply; everything else (name, subject, sender, groups, segments, language) is kept. Fails if the campaign is not a draft. It never sends or schedules. Get the ID from list_campaigns with status draft. Pass group_ids or segment_ids (not both) to replace the audience. Fails without changing anything if the campaign excludes groups or segments, or targets both groups and segments and you pass neither, because the MailerLite API cannot save those rules back. content (HTML) works on the Advanced plan only. At least one value is required. Idempotent.',
+ idempotent: true,
+ },
+ outputSchema: campaignWriteOutputSchema,
+ props: {
+ campaign_id: Property.ShortText({
+ displayName: 'Campaign ID',
+ description: 'The draft campaign ID, from list_campaigns.',
+ required: true,
+ }),
+ name: Property.ShortText({
+ displayName: 'Campaign Name',
+ description: 'New internal campaign name.',
+ required: false,
+ }),
+ subject: Property.ShortText({
+ displayName: 'Subject',
+ description: 'New email subject line.',
+ required: false,
+ }),
+ from_name: Property.ShortText({
+ displayName: 'From Name',
+ description: 'New sender name.',
+ required: false,
+ }),
+ from: Property.ShortText({
+ displayName: 'From Email',
+ description: 'New sender email. Must be a verified sender on the MailerLite account.',
+ required: false,
+ }),
+ reply_to: Property.ShortText({
+ displayName: 'Reply-To Email',
+ description: 'New reply-to email address.',
+ required: false,
+ }),
+ group_ids: Property.Array({
+ displayName: 'Group IDs',
+ description: 'Replaces the audience with these groups, from list_groups. Not with Segment IDs.',
+ required: false,
+ }),
+ segment_ids: Property.Array({
+ displayName: 'Segment IDs',
+ description: 'Replaces the audience with these segments, from list_segments. Not with Group IDs.',
+ required: false,
+ }),
+ language_id: Property.ShortText({
+ displayName: 'Language ID',
+ description: 'Campaign language ID, from list_campaign_languages.',
+ required: false,
+ }),
+ content: Property.LongText({
+ displayName: 'HTML Content',
+ description: 'Email HTML. Advanced plan only; omit on other plans.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.campaign_id, label: 'Campaign ID' });
+ const props = context.propsValue;
+ const groupPatch = toIdList(props.group_ids);
+ const segmentPatch = toIdList(props.segment_ids);
+ const hasPatch =
+ [props.name, props.subject, props.from_name, props.from, props.reply_to, props.language_id, props.content].some(
+ (value) => typeof value === 'string' && value.length > 0,
+ ) ||
+ groupPatch !== undefined ||
+ segmentPatch !== undefined;
+ if (!hasPatch) {
+ throw new Error('Provide at least one value to update.');
+ }
+ if (groupPatch !== undefined && segmentPatch !== undefined) {
+ throw new Error('Provide Group IDs or Segment IDs, not both: MailerLite only sends to the segments when both are set.');
+ }
+ const current = mailerLiteApi.unwrapData(
+ await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.GET,
+ path: `/campaigns/${id}`,
+ resource: `campaign ${id}`,
+ }),
+ );
+ if (current['status'] !== 'draft') {
+ throw new Error(`Campaign ${id} is not a draft (status: ${String(current['status'])}); only drafts can be updated.`);
+ }
+ const targeting = currentTargeting(current);
+ if (!targeting.representable) {
+ throw new Error(
+ `Campaign ${id} uses audience rules the MailerLite API cannot save back, such as excluded groups or segments. Update it in MailerLite instead so those rules are not lost.`,
+ );
+ }
+ const emails = current['emails'];
+ const firstEmail = Array.isArray(emails) && mailerLiteApi.isRecord(emails[0]) ? emails[0] : {};
+ const email: Record = {};
+ const subject = props.subject || textOf(firstEmail['subject']);
+ const fromName = props.from_name || textOf(firstEmail['from_name']);
+ const from = props.from || textOf(firstEmail['from']);
+ const replyTo = props.reply_to || textOf(firstEmail['reply_to']);
+ if (subject) {
+ email['subject'] = subject;
+ }
+ if (fromName) {
+ email['from_name'] = fromName;
+ }
+ if (from) {
+ email['from'] = from;
+ }
+ if (replyTo) {
+ email['reply_to'] = replyTo;
+ }
+ const content = props.content || textOf(firstEmail['content']);
+ if (content) {
+ email['content'] = content;
+ }
+ const body: Record = {
+ name: props.name || textOf(current['name']),
+ emails: [email],
+ };
+ if (groupPatch !== undefined) {
+ body['groups'] = groupPatch;
+ } else if (segmentPatch !== undefined) {
+ body['segments'] = segmentPatch;
+ } else if (targeting.segments.length > 0) {
+ if (targeting.groups.length > 0) {
+ throw new Error(
+ `Campaign ${id} targets both groups and segments, which the MailerLite API cannot save back together. Pass Group IDs or Segment IDs to choose the audience.`,
+ );
+ }
+ body['segments'] = targeting.segments;
+ } else if (targeting.groups.length > 0) {
+ body['groups'] = targeting.groups;
+ }
+ const languageId = props.language_id || current['language_id'];
+ if (languageId !== undefined && languageId !== null && languageId !== '') {
+ body['language_id'] = String(languageId);
+ }
+ const response = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.PUT,
+ path: `/campaigns/${id}`,
+ resource: `campaign ${id}`,
+ body,
+ });
+ return mailerLiteApi.unwrapData(response);
+ },
+});
+
+type Targeting = {
+ groups: string[];
+ segments: string[];
+ representable: boolean;
+};
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/update-subscriber.ts b/packages/pieces/community/mailer-lite/src/lib/actions/update-subscriber.ts
new file mode 100644
index 000000000000..91643bb23dab
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/update-subscriber.ts
@@ -0,0 +1,108 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import dayjs from 'dayjs';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { updateSubscriberOutputSchema } from '../output-schemas';
+
+function formatDate(value: string | undefined): string | undefined {
+ if (value === undefined || value === null || value === '') {
+ return undefined;
+ }
+ const parsed = dayjs(value);
+ if (!parsed.isValid()) {
+ throw new Error(`Invalid date: ${value}`);
+ }
+ return parsed.format('YYYY-MM-DD HH:mm:ss');
+}
+
+export const updateSubscriberAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'update_subscriber',
+ classification: 'WRITE',
+ displayName: 'Update Subscriber',
+ description: 'Update fields or status of an existing subscriber.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Update an existing MailerLite subscriber by ID, sending only the values you supply: custom or default field values (fields object keyed by field key from list_fields), status, dates and IPs. Group membership is never changed here; use add_subscriber_to_group or remove_subscriber_from_group. The API cannot reactivate unsubscribed, bounced or junk subscribers. Get the ID from find_subscriber. At least one value is required. Idempotent.',
+ idempotent: true,
+ },
+ outputSchema: updateSubscriberOutputSchema,
+ props: {
+ subscriber_id: Property.ShortText({
+ displayName: 'Subscriber ID',
+ description: 'The subscriber ID, from find_subscriber or list_subscribers.',
+ required: true,
+ }),
+ fields: Property.Object({
+ displayName: 'Fields',
+ description: 'Field values to set, keyed by field key (e.g. name, last_name, or a custom field key from list_fields). Fields not listed are left alone.',
+ required: false,
+ }),
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'New subscriber status. Unsubscribed, bounced and junk subscribers cannot be reactivated through the API.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Active', value: 'active' },
+ { label: 'Unsubscribed', value: 'unsubscribed' },
+ { label: 'Unconfirmed', value: 'unconfirmed' },
+ { label: 'Bounced', value: 'bounced' },
+ { label: 'Junk', value: 'junk' },
+ ],
+ },
+ }),
+ subscribed_at: Property.DateTime({
+ displayName: 'Subscribed At',
+ description: 'When the subscriber subscribed.',
+ required: false,
+ }),
+ opted_in_at: Property.DateTime({
+ displayName: 'Opted In At',
+ description: 'When the subscriber confirmed the opt-in.',
+ required: false,
+ }),
+ unsubscribed_at: Property.DateTime({
+ displayName: 'Unsubscribed At',
+ description: 'When the subscriber unsubscribed.',
+ required: false,
+ }),
+ ip_address: Property.ShortText({
+ displayName: 'IP Address',
+ description: 'The IP address the subscriber signed up from.',
+ required: false,
+ }),
+ optin_ip: Property.ShortText({
+ displayName: 'Opt-in IP',
+ description: 'The IP address the subscriber confirmed the opt-in from.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.subscriber_id, label: 'Subscriber ID' });
+ const { fields, status, ip_address, optin_ip } = context.propsValue;
+ const candidate: Record = {
+ fields: fields && Object.keys(fields).length > 0 ? fields : undefined,
+ status: status || undefined,
+ subscribed_at: formatDate(context.propsValue.subscribed_at),
+ opted_in_at: formatDate(context.propsValue.opted_in_at),
+ unsubscribed_at: formatDate(context.propsValue.unsubscribed_at),
+ ip_address: ip_address || undefined,
+ optin_ip: optin_ip || undefined,
+ };
+ const body = Object.fromEntries(Object.entries(candidate).filter(([, value]) => value !== undefined));
+ if (Object.keys(body).length === 0) {
+ throw new Error('Provide at least one value to update.');
+ }
+ const response = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.PUT,
+ path: `/subscribers/${id}`,
+ resource: `subscriber ${id}`,
+ body,
+ });
+ return mailerLiteApi.unwrapData(response);
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/actions/update-webhook.ts b/packages/pieces/community/mailer-lite/src/lib/actions/update-webhook.ts
new file mode 100644
index 000000000000..28b6e84a623e
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/actions/update-webhook.ts
@@ -0,0 +1,86 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { Property, createAction } from '@activepieces/pieces-framework';
+import { mailerLiteAuth } from '../auth';
+import { mailerLiteApi } from '../common/client';
+import { webhookOutputSchema } from '../output-schemas';
+
+export const updateWebhookAction = createAction({
+ auth: mailerLiteAuth,
+ name: 'update_webhook',
+ classification: 'WRITE',
+ displayName: 'Update Webhook',
+ description: 'Update a webhook, changing only the values you supply.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Update a MailerLite webhook by ID, sending only the values you supply (name, URL, events, enabled, batchable); the rest are kept. A supplied events list replaces the current one. The signing secret is never returned. Get the ID from list_webhooks. Webhooks created by Activepieces MailerLite triggers are listed too; changing one can break that flow. At least one value is required. Idempotent.',
+ idempotent: true,
+ },
+ outputSchema: webhookOutputSchema,
+ props: {
+ webhook_id: Property.ShortText({
+ displayName: 'Webhook ID',
+ description: 'The webhook ID, from list_webhooks.',
+ required: true,
+ }),
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'New webhook name.',
+ required: false,
+ }),
+ url: Property.ShortText({
+ displayName: 'URL',
+ description: 'New HTTPS URL MailerLite will POST events to.',
+ required: false,
+ }),
+ events: Property.StaticMultiSelectDropdown({
+ displayName: 'Events',
+ description: 'Replaces the events that trigger the webhook.',
+ required: false,
+ options: { options: mailerLiteApi.webhookEventOptions() },
+ }),
+ enabled: Property.StaticDropdown({
+ displayName: 'Enabled',
+ description: 'Whether the webhook is active.',
+ required: false,
+ options: { options: mailerLiteApi.booleanOptions() },
+ }),
+ batchable: Property.StaticDropdown({
+ displayName: 'Batchable',
+ description: 'Whether events are delivered in batches.',
+ required: false,
+ options: { options: mailerLiteApi.booleanOptions() },
+ }),
+ },
+ async run(context) {
+ const id = mailerLiteApi.requireId({ value: context.propsValue.webhook_id, label: 'Webhook ID' });
+ const { name, url, events, enabled, batchable } = context.propsValue;
+ const body: Record = {};
+ if (name) {
+ body['name'] = name;
+ }
+ if (url && url.trim()) {
+ body['url'] = url.trim();
+ }
+ if (events && events.length > 0) {
+ body['events'] = events;
+ }
+ if (enabled === 'true' || enabled === 'false') {
+ body['enabled'] = enabled === 'true';
+ }
+ if (batchable === 'true' || batchable === 'false') {
+ body['batchable'] = batchable === 'true';
+ }
+ if (Object.keys(body).length === 0) {
+ throw new Error('Provide at least one value to update.');
+ }
+ const response = await mailerLiteApi.request({
+ apiKey: context.auth.secret_text,
+ method: HttpMethod.PUT,
+ path: `/webhooks/${id}`,
+ resource: `webhook ${id}`,
+ body,
+ });
+ return mailerLiteApi.withoutSecret(mailerLiteApi.unwrapData(response));
+ },
+});
diff --git a/packages/pieces/community/mailer-lite/src/lib/common/client.ts b/packages/pieces/community/mailer-lite/src/lib/common/client.ts
new file mode 100644
index 000000000000..09dc9e0105ba
--- /dev/null
+++ b/packages/pieces/community/mailer-lite/src/lib/common/client.ts
@@ -0,0 +1,190 @@
+import {
+ AuthenticationType,
+ HttpError,
+ HttpMethod,
+ QueryParams,
+ httpClient,
+} from '@activepieces/pieces-common';
+
+const BASE_URL = 'https://connect.mailerlite.com/api';
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function describeFieldErrors(errors: unknown): string {
+ if (!isRecord(errors)) {
+ return '';
+ }
+ return Object.entries(errors)
+ .map(([field, messages]) => {
+ const text = Array.isArray(messages) ? messages.map(String).join(', ') : String(messages);
+ return `${field}: ${text}`;
+ })
+ .join('; ');
+}
+
+function toReadableError({ error, resource }: { error: HttpError; resource: string }): Error {
+ const status = error.response.status;
+ const body = error.response.body;
+ const vendorMessage = isRecord(body) && typeof body['message'] === 'string' ? body['message'] : '';
+ switch (status) {
+ case 401:
+ return new Error('MailerLite rejected the API token (invalid or revoked). Reconnect with a valid token.');
+ case 404:
+ return new Error(`MailerLite could not find ${resource}. Check the ID.`);
+ case 410:
+ return new Error(`MailerLite reports ${resource} was already deleted.`);
+ case 422: {
+ const fieldErrors = isRecord(body) ? describeFieldErrors(body['errors']) : '';
+ return new Error(
+ `MailerLite validation failed: ${vendorMessage}${fieldErrors ? ` (${fieldErrors})` : ''}`,
+ );
+ }
+ case 429:
+ return new Error(
+ 'MailerLite rate limit reached (120 requests/minute, 5/minute for imports). Wait and retry.',
+ );
+ case 400:
+ return new Error(`MailerLite rejected the request: ${vendorMessage}`);
+ default:
+ return new Error(`MailerLite request failed with status ${status}: ${vendorMessage || JSON.stringify(body)}`);
+ }
+}
+
+async function request({
+ apiKey,
+ method,
+ path,
+ resource,
+ queryParams,
+ body,
+}: RequestParams): Promise {
+ const query: QueryParams = {};
+ Object.entries(queryParams ?? {}).forEach(([key, value]) => {
+ if (value !== undefined && value !== null && value !== '') {
+ query[key] = String(value);
+ }
+ });
+ try {
+ const response = await httpClient.sendRequest({
+ method,
+ url: `${BASE_URL}${path}`,
+ authentication: { type: AuthenticationType.BEARER_TOKEN, token: apiKey },
+ headers: { Accept: 'application/json' },
+ queryParams: query,
+ body,
+ });
+ return response.body;
+ } catch (error) {
+ if (error instanceof HttpError) {
+ throw toReadableError({ error, resource: resource ?? `the resource at ${path}` });
+ }
+ throw error;
+ }
+}
+
+function readableSdkError({ error, resource }: { error: unknown; resource: string }): unknown {
+ if (!isRecord(error) || !isRecord(error['response']) || typeof error['response']['status'] !== 'number') {
+ return error;
+ }
+ const status = error['response']['status'];
+ if (status === 404) {
+ return new Error(`MailerLite could not find ${resource}; it may not exist or was already deleted.`);
+ }
+ return toReadableError({
+ error: new HttpError(undefined, { status, responseBody: error['response']['data'] }),
+ resource,
+ });
+}
+
+function unwrapData(body: unknown): Record {
+ if (isRecord(body) && isRecord(body['data'])) {
+ return body['data'];
+ }
+ return isRecord(body) ? body : {};
+}
+
+function withoutSecret(item: unknown): unknown {
+ if (!isRecord(item)) {
+ return item;
+ }
+ return Object.fromEntries(Object.entries(item).filter(([key]) => key !== 'secret'));
+}
+
+function resolveLimit({ value, fallback, max }: { value: number | undefined; fallback: number; max: number }): number {
+ const resolved = Math.trunc(Number(value ?? fallback));
+ if (!Number.isFinite(resolved) || resolved < 1 || resolved > max) {
+ throw new Error(`Limit must be between 1 and ${max}.`);
+ }
+ return resolved;
+}
+
+function resolvePage(value: number | undefined): number | undefined {
+ if (value === undefined || value === null) {
+ return undefined;
+ }
+ const resolved = Math.trunc(Number(value));
+ if (!Number.isFinite(resolved) || resolved < 1) {
+ throw new Error('Page must be 1 or greater.');
+ }
+ return resolved;
+}
+
+function requireId({ value, label }: { value: string | undefined; label: string }): string {
+ const trimmed = (value ?? '').trim();
+ if (!trimmed) {
+ throw new Error(`${label} is required.`);
+ }
+ return encodeURIComponent(trimmed);
+}
+
+const WEBHOOK_EVENTS = [
+ 'subscriber.created',
+ 'subscriber.updated',
+ 'subscriber.unsubscribed',
+ 'subscriber.added_to_group',
+ 'subscriber.removed_from_group',
+ 'subscriber.bounced',
+ 'subscriber.automation_triggered',
+ 'subscriber.automation_completed',
+ 'subscriber.spam_reported',
+ 'subscriber.deleted',
+ 'subscriber.active',
+ 'campaign.sent',
+ 'campaign.click',
+ 'campaign.open',
+];
+
+function webhookEventOptions(): { label: string; value: string }[] {
+ return WEBHOOK_EVENTS.map((event) => ({ label: event, value: event }));
+}
+
+function booleanOptions(): { label: string; value: string }[] {
+ return [
+ { label: 'Yes', value: 'true' },
+ { label: 'No', value: 'false' },
+ ];
+}
+
+type RequestParams = {
+ apiKey: string;
+ method: HttpMethod;
+ path: string;
+ resource?: string;
+ queryParams?: Record;
+ body?: unknown;
+};
+
+export const mailerLiteApi = {
+ request,
+ unwrapData,
+ readableSdkError,
+ withoutSecret,
+ isRecord,
+ resolveLimit,
+ resolvePage,
+ requireId,
+ webhookEventOptions,
+ booleanOptions,
+};
diff --git a/packages/pieces/community/mailer-lite/src/lib/output-schemas.ts b/packages/pieces/community/mailer-lite/src/lib/output-schemas.ts
index 6bd7e09f6ea9..1a47a34d7494 100644
--- a/packages/pieces/community/mailer-lite/src/lib/output-schemas.ts
+++ b/packages/pieces/community/mailer-lite/src/lib/output-schemas.ts
@@ -102,7 +102,7 @@ const single = (fields: Fields): OutputSchema => ({
fields: [{ key: 'data', label: 'Data', children: fields }],
});
-const cursorList = (label: string, fields: Fields): OutputSchema => ({
+const cursorList = ({ label, fields }: { label: string; fields: Fields }): OutputSchema => ({
fields: [
{ key: 'data', label, listItems: fields, labelKey: 'id' },
{ key: 'links', label: 'Links', children: linksFields },
@@ -110,7 +110,7 @@ const cursorList = (label: string, fields: Fields): OutputSchema => ({
],
});
-const pageList = (label: string, fields: Fields): OutputSchema => ({
+const pageList = ({ label, fields }: { label: string; fields: Fields }): OutputSchema => ({
fields: [
{ key: 'data', label, listItems: fields, labelKey: 'name' },
{ key: 'links', label: 'Links', children: linksFields },
@@ -118,15 +118,427 @@ const pageList = (label: string, fields: Fields): OutputSchema => ({
],
});
+const humanize = (key: string): string =>
+ key
+ .split('_')
+ .map((part) => (part.length > 0 ? part[0].toUpperCase() + part.slice(1) : part))
+ .join(' ');
+
+const named = (keys: string[]): Fields => keys.map((key) => ({ key, label: humanize(key) }));
+
+const rates = (keys: string[]): Fields => keys.map((key) => ({ key, label: humanize(key), children: rateFields }));
+
+const segmentFields: Fields = [
+ { key: 'id', label: 'Segment ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'total', label: 'Total Subscribers', format: 'number' },
+ { key: 'automations_using_segment_count', label: 'Automations Using Segment', format: 'number' },
+ { key: 'open_rate', label: 'Open Rate', children: rateFields },
+ { key: 'click_rate', label: 'Click Rate', children: rateFields },
+ { key: 'created_at', label: 'Created At', format: 'datetime' },
+];
+
+const fieldFields: Fields = [
+ { key: 'id', label: 'Field ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'key', label: 'Key' },
+ { key: 'type', label: 'Type' },
+ { key: 'is_default', label: 'Default Field', format: 'boolean' },
+];
+
+const listedFieldFields: Fields = [
+ ...fieldFields,
+ { key: 'used_in_automations', label: 'Used In Automations', format: 'boolean' },
+];
+
+const automationStatsFields: Fields = [
+ ...named([
+ 'completed_subscribers_count',
+ 'subscribers_in_queue_count',
+ 'sent',
+ 'opens_count',
+ 'unique_opens_count',
+ 'clicks_count',
+ 'unique_clicks_count',
+ 'unsubscribes_count',
+ 'spam_count',
+ 'hard_bounces_count',
+ 'soft_bounces_count',
+ 'social_interactions_count',
+ ]),
+ ...rates([
+ 'bounce_rate',
+ 'click_to_open_rate',
+ 'open_rate',
+ 'click_rate',
+ 'unsubscribe_rate',
+ 'spam_rate',
+ 'hard_bounce_rate',
+ 'soft_bounce_rate',
+ 'forward_rate',
+ 'social_interaction_rate',
+ ]),
+];
+
+const automationFields: Fields = [
+ { key: 'id', label: 'Automation ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'enabled', label: 'Enabled', format: 'boolean' },
+ { key: 'trigger_data', label: 'Trigger Data', children: named(['track_ecommerce', 'broken_workflow', 'complete_workflow']) },
+ { key: 'steps', label: 'Steps' },
+ { key: 'triggers', label: 'Triggers' },
+ { key: 'complete', label: 'Complete', format: 'boolean' },
+ { key: 'broken', label: 'Broken', format: 'boolean' },
+ { key: 'warnings', label: 'Warnings' },
+ { key: 'emails_count', label: 'Emails Count', format: 'number' },
+ { key: 'first_email_screenshot_url', label: 'First Email Screenshot URL', format: 'url' },
+ { key: 'stats', label: 'Stats', children: automationStatsFields },
+ { key: 'created_at', label: 'Created At', format: 'datetime' },
+ { key: 'has_banned_content', label: 'Has Banned Content', format: 'boolean' },
+ { key: 'building_in_progress', label: 'Building In Progress', format: 'boolean' },
+ { key: 'automation_template_id', label: 'Template ID' },
+ { key: 'automation_template_name', label: 'Template Name' },
+ { key: 'classic_sync_in_progress', label: 'Classic Sync In Progress', format: 'boolean' },
+ { key: 'classic_sync_activate_after', label: 'Classic Sync Activate After' },
+];
+
+const automationDetailFields: Fields = [
+ ...automationFields.filter((field) => field.key !== 'first_email_screenshot_url'),
+ { key: 'heylite_inactive_count', label: 'Heylite Inactive Count', format: 'number' },
+ { key: 'heylite_recommendation_id', label: 'Heylite Recommendation ID' },
+];
+
+const createdAutomationFields: Fields = automationFields.filter(
+ (field) => field.key !== 'first_email_screenshot_url' && field.key !== 'triggers',
+);
+
+const languageFields: Fields = [
+ { key: 'id', label: 'Language ID' },
+ { key: 'shortcode', label: 'Shortcode' },
+ { key: 'iso639', label: 'ISO 639' },
+ { key: 'name', label: 'Name' },
+ { key: 'direction', label: 'Direction' },
+];
+
+const campaignEmailStatsFields: Fields = [
+ ...named([
+ 'sent',
+ 'opens_count',
+ 'clicks_count',
+ 'unsubscribes_count',
+ 'spam_count',
+ 'hard_bounces_count',
+ 'soft_bounces_count',
+ 'deliveries_count',
+ 'forwards_count',
+ 'social_interactions_count',
+ ]),
+ ...rates([
+ 'open_rate',
+ 'click_rate',
+ 'unsubscribe_rate',
+ 'spam_rate',
+ 'hard_bounce_rate',
+ 'soft_bounce_rate',
+ 'delivery_rate',
+ 'forward_rate',
+ 'social_interaction_rate',
+ 'click_to_open_rate',
+ ]),
+];
+
+const campaignEmailFields: Fields = [
+ ...named([
+ 'id',
+ 'account_id',
+ 'emailable_id',
+ 'emailable_type',
+ 'type',
+ 'from',
+ 'from_name',
+ 'reply_to',
+ 'name',
+ 'subject',
+ 'content',
+ 'plain_text',
+ 'screenshot_url',
+ 'uses_new_builder',
+ 'uses_landscape_screenshot',
+ 'generate_screenshot_timestamp',
+ 'is_building_content',
+ 'preview_url',
+ 'report_preview_url',
+ 'created_at',
+ 'updated_at',
+ 'is_designed',
+ 'language_id',
+ 'is_winner',
+ 'send_after',
+ 'track_opens',
+ 'uses_survey',
+ 'uses_quiz',
+ 'preheader',
+ ]),
+ { key: 'language', label: 'Language', children: languageFields },
+ { key: 'stats', label: 'Stats', children: campaignEmailStatsFields },
+];
+
+const filterGroupFields: Fields = [
+ ...groupFields.map((field) => (field.children ? { key: field.key, label: field.label } : field)),
+ ...named(['account_id', 'updated_at', 'deleted_at', 'serial_no']),
+];
+
+const campaignWriteFields: Fields = [
+ ...named([
+ 'id',
+ 'account_id',
+ 'name',
+ 'type',
+ 'status',
+ 'missing_data',
+ 'filter',
+ 'filter_for_humans',
+ 'delivery_schedule',
+ 'language_id',
+ 'created_at',
+ 'updated_at',
+ 'scheduled_for',
+ 'queued_at',
+ 'started_at',
+ 'finished_at',
+ 'stopped_at',
+ 'default_email_id',
+ 'used_in_automations',
+ 'type_for_humans',
+ 'is_stopped',
+ 'has_winner',
+ 'winner_version_for_human',
+ 'winner_sending_time_for_humans',
+ 'winner_selected_manually_at',
+ 'uses_ecommerce',
+ 'ecommerce_stats',
+ 'uses_survey',
+ 'is_smart_sending_index_option_finished',
+ 'is_applied_for_smart_sending_index_option',
+ 'warnings',
+ 'recipients_count',
+ 'next_step',
+ 'is_currently_sending_out',
+ 'can_be_copied',
+ 'has_basic_filter',
+ 'is_eligible_for_sending',
+ 'subscriber_import_limit_exceeded',
+ 'needs_repair',
+ ]),
+ { key: 'settings', label: 'Settings', children: named(['track_opens', 'use_google_analytics', 'ecommerce_tracking']) },
+ { key: 'language', label: 'Language', children: languageFields },
+ { key: 'can', label: 'Permissions', children: named(['update', 'delete', 'send', 'copy', 'resend']) },
+ { key: 'emails', label: 'Emails', listItems: campaignEmailFields, labelKey: 'subject' },
+ {
+ key: 'basic_filter_for_humans',
+ label: 'Audience',
+ children: [
+ { key: 'all_active_subscribers', label: 'All Active Subscribers' },
+ { key: 'included_groups', label: 'Included Groups', listItems: filterGroupFields, labelKey: 'name' },
+ { key: 'excluded_groups', label: 'Excluded Groups' },
+ { key: 'included_segments', label: 'Included Segments' },
+ { key: 'excluded_segments', label: 'Excluded Segments' },
+ ],
+ },
+];
+
+const campaignFields: Fields = [
+ ...campaignWriteFields.map((field) =>
+ field.key === 'emails'
+ ? { ...field, listItems: [...campaignEmailFields, { key: 'click_map', label: 'Click Map' }] }
+ : field,
+ ),
+ { key: 'can_be_scheduled', label: 'Can Be Scheduled', format: 'boolean' },
+ { key: 'cannot_be_scheduled_reason', label: 'Cannot Be Scheduled Reason' },
+];
+
+const formFields: Fields = [
+ { key: 'id', label: 'Form ID' },
+ { key: 'type', label: 'Type' },
+ { key: 'slug', label: 'Slug' },
+ { key: 'name', label: 'Name' },
+ { key: 'created_at', label: 'Created At', format: 'datetime' },
+ { key: 'conversions_count', label: 'Conversions', format: 'number' },
+ { key: 'conversion_rate', label: 'Conversion Rate', children: rateFields },
+ { key: 'opens_count', label: 'Opens Count', format: 'number' },
+ { key: 'settings', label: 'Settings' },
+ { key: 'last_registration_at', label: 'Last Registration At', format: 'datetime' },
+ { key: 'active', label: 'Active', format: 'boolean' },
+ { key: 'is_broken', label: 'Broken', format: 'boolean' },
+ { key: 'has_content', label: 'Has Content', format: 'boolean' },
+ { key: 'used_in_automations', label: 'Used In Automations', format: 'boolean' },
+ { key: 'warnings', label: 'Warnings' },
+ { key: 'double_optin', label: 'Double Opt-in', format: 'boolean' },
+ { key: 'screenshot_url', label: 'Screenshot URL', format: 'url' },
+ { key: 'share_url', label: 'Share URL', format: 'url' },
+ { key: 'groups', label: 'Groups', listItems: groupFields, labelKey: 'name' },
+ { key: 'can', label: 'Permissions', children: named(['update']) },
+ { key: 'conversion_rate_including_children', label: 'Conversion Rate Including Children', children: rateFields },
+ ...named([
+ 'account_id',
+ 'original_name',
+ 'parent_id',
+ 'children',
+ 'has_missing_groups',
+ 'split_test_enabled',
+ 'split_test_finished',
+ 'has_split_tests',
+ 'split_percentage',
+ 'warning_messages',
+ 'uses_new_builder',
+ 'uses_survey',
+ 'can_choose_template',
+ 'is_split_test_winner',
+ 'analytics_reset_at',
+ 'conversions_count_including_children',
+ 'opens_count_including_children',
+ ]),
+];
+
+const formDetailFields: Fields = formFields.map((field) =>
+ field.key === 'settings' ? { ...field, children: named(['double_optin', 'groot_id', 'automated_content_check_performed']) } : field,
+);
+
+const listedFormFields: Fields = formFields.map((field) =>
+ field.key === 'settings' ? { ...field, children: named([
+ 'double_optin',
+ 'form_type',
+ 'groot_id',
+ 'automated_content_check_performed',
+ 'is_first_form_with_content',
+ 'needs_content_refresh',
+ ]) } : field,
+);
+
+const webhookFields: Fields = [
+ { key: 'id', label: 'Webhook ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'url', label: 'URL', format: 'url' },
+ { key: 'events', label: 'Events' },
+ { key: 'enabled', label: 'Enabled', format: 'boolean' },
+ { key: 'batchable', label: 'Batchable', format: 'boolean' },
+ { key: 'is_blocked', label: 'Blocked', format: 'boolean' },
+ { key: 'last_fired_at', label: 'Last Fired At', format: 'datetime' },
+ { key: 'response_code', label: 'Last Response Code' },
+ { key: 'version', label: 'Version' },
+ { key: 'created_at', label: 'Created At', format: 'datetime' },
+ { key: 'updated_at', label: 'Updated At', format: 'datetime' },
+];
+
+const subscriberActivityFields: Fields = [
+ { key: 'id', label: 'Activity ID' },
+ { key: 'log_name', label: 'Activity Type' },
+ { key: 'subject_id', label: 'Subject ID' },
+ { key: 'subject_type', label: 'Subject Type' },
+ { key: 'properties', label: 'Properties', children: [
+ { key: 'group_id', label: 'Group ID' },
+ { key: 'group_name', label: 'Group Name' },
+ ] },
+ { key: 'created_at', label: 'Created At', format: 'datetime' },
+ { key: 'updated_at', label: 'Updated At', format: 'datetime' },
+];
+
+const automationActivityFields: Fields = [
+ { key: 'id', label: 'Activity ID' },
+ { key: 'status', label: 'Status' },
+ { key: 'subscriber', label: 'Subscriber' },
+ { key: 'date', label: 'Date', format: 'datetime' },
+ { key: 'scheduled_for', label: 'Scheduled For', format: 'datetime' },
+ { key: 'reason', label: 'Reason' },
+ { key: 'reason_description', label: 'Reason Description' },
+];
+
+const importFields: Fields = [
+ ...named([
+ 'id',
+ 'account_id',
+ 'total',
+ 'processed',
+ 'imported',
+ 'updated',
+ 'errored',
+ 'percent',
+ 'done',
+ 'invalid',
+ 'invalid_count',
+ 'mistyped',
+ 'mistyped_count',
+ 'changed',
+ 'changed_count',
+ 'unchanged',
+ 'unchanged_count',
+ 'unsubscribed',
+ 'unsubscribed_count',
+ 'role_based',
+ 'role_based_count',
+ 'suspicious_format',
+ 'suspicious_format_count',
+ 'duplicate',
+ 'duplicate_count',
+ 'subscriber_limit',
+ 'subscriber_limit_count',
+ 'banned_import_emails_count',
+ 'updated_at',
+ 'undone_at',
+ 'is_stopped_due_to_free_account_limit_exceeded',
+ 'stopped_at',
+ 'undo_started_at',
+ 'finished_at',
+ ]),
+];
+
+const timezoneFields: Fields = [
+ { key: 'id', label: 'Timezone ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'name_for_humans', label: 'Display Name' },
+ { key: 'offset_name', label: 'Offset Name' },
+ { key: 'offset', label: 'Offset', format: 'number' },
+];
+
+const subscriberWithGroupListFields: Fields = [
+ ...subscriberFields,
+ { key: 'location', label: 'Location' },
+ { key: 'groups', label: 'Groups', listItems: groupFields, labelKey: 'name' },
+];
+
+const forgottenSubscriberFields: Fields = [
+ ...webhookSubscriberFields,
+ { key: 'groups', label: 'Group IDs' },
+];
+
+const plainList = ({ label, fields, labelKey = 'name' }: { label: string; fields: Fields; labelKey?: string }): OutputSchema => ({
+ fields: [{ key: 'data', label, listItems: fields, labelKey }],
+});
+
+const withAggregations = ({ schema, keys }: { schema: OutputSchema; keys: string[] }): OutputSchema => ({
+ fields: schema.fields.map((field) =>
+ field.key === 'meta'
+ ? { ...field, children: [...(field.children ?? []), { key: 'aggregations', label: 'Aggregations', children: named(keys) }] }
+ : field,
+ ),
+});
+
+const deleted = ({ idKey, idLabel }: { idKey: string; idLabel: string }): OutputSchema => ({
+ fields: [
+ { key: 'deleted', label: 'Deleted', format: 'boolean' },
+ { key: idKey, label: idLabel },
+ ],
+});
+
export const findSubscriberOutputSchema: OutputSchema = single(subscriberWithGroupsFields);
export const addOrUpdateSubscriberOutputSchema: OutputSchema = single(subscriberWithGroupsFields);
export const unsubscribeSubscriberOutputSchema: OutputSchema = single(unsubscribedSubscriberFields);
-export const listSubscribersOutputSchema: OutputSchema = cursorList('Subscribers', subscriberFields);
+export const listSubscribersOutputSchema: OutputSchema = cursorList({ label: 'Subscribers', fields: subscriberFields });
export const addSubscriberToGroupOutputSchema: OutputSchema = single(groupFields);
export const createGroupOutputSchema: OutputSchema = single(groupFields);
-export const listGroupsOutputSchema: OutputSchema = pageList('Groups', groupFields);
-export const listGroupSubscribersOutputSchema: OutputSchema = cursorList('Subscribers', subscriberFields);
+export const listGroupsOutputSchema: OutputSchema = pageList({ label: 'Groups', fields: groupFields });
+export const listGroupSubscribersOutputSchema: OutputSchema = cursorList({ label: 'Subscribers', fields: subscriberFields });
export const deleteSubscriberOutputSchema: OutputSchema = {
fields: [
@@ -154,3 +566,44 @@ export const subscriberAddedToGroupTriggerOutputSchema: OutputSchema = {
...webhookEnvelopeFields,
],
};
+
+export const updateSubscriberOutputSchema: OutputSchema = { fields: subscriberWithGroupListFields };
+export const forgetSubscriberOutputSchema: OutputSchema = { fields: forgottenSubscriberFields };
+export const subscriberActivityOutputSchema: OutputSchema = plainList({ label: 'Activity', fields: subscriberActivityFields, labelKey: 'log_name' });
+export const countSubscribersOutputSchema: OutputSchema = {
+ fields: [{ key: 'total', label: 'Total Subscribers', format: 'number' }],
+};
+export const importSubscribersOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'import_id', label: 'Import ID' },
+ { key: 'import_progress_url', label: 'Import Progress URL', format: 'url' },
+ ],
+};
+export const importStatusOutputSchema: OutputSchema = { fields: importFields };
+export const findGroupsByNameOutputSchema: OutputSchema = pageList({ label: 'Groups', fields: groupFields });
+export const groupOutputSchema: OutputSchema = { fields: groupFields };
+export const deleteGroupOutputSchema: OutputSchema = deleted({ idKey: 'group_id', idLabel: 'Group ID' });
+export const listSegmentsOutputSchema: OutputSchema = pageList({ label: 'Segments', fields: segmentFields });
+export const listSegmentSubscribersOutputSchema: OutputSchema = cursorList({ label: 'Subscribers', fields: [...subscriberFields, { key: 'groups', label: 'Groups' }] });
+export const segmentOutputSchema: OutputSchema = { fields: segmentFields.filter((field) => field.key !== 'automations_using_segment_count') };
+export const deleteSegmentOutputSchema: OutputSchema = deleted({ idKey: 'segment_id', idLabel: 'Segment ID' });
+export const listFieldsOutputSchema: OutputSchema = pageList({ label: 'Fields', fields: listedFieldFields });
+export const fieldOutputSchema: OutputSchema = { fields: fieldFields };
+export const deleteFieldOutputSchema: OutputSchema = deleted({ idKey: 'field_id', idLabel: 'Field ID' });
+export const listAutomationsOutputSchema: OutputSchema = withAggregations({ schema: pageList({ label: 'Automations', fields: automationFields }), keys: ['workflows', 'drafts', 'unsync', 'all'] });
+export const automationOutputSchema: OutputSchema = { fields: automationDetailFields };
+export const automationActivityOutputSchema: OutputSchema = pageList({ label: 'Activity', fields: automationActivityFields });
+export const deleteAutomationOutputSchema: OutputSchema = deleted({ idKey: 'automation_id', idLabel: 'Automation ID' });
+export const listCampaignsOutputSchema: OutputSchema = withAggregations({ schema: pageList({ label: 'Campaigns', fields: campaignFields }), keys: ['all', 'draft', 'ready', 'sent'] });
+export const campaignOutputSchema: OutputSchema = { fields: campaignFields };
+export const campaignWriteOutputSchema: OutputSchema = { fields: campaignWriteFields };
+export const createAutomationOutputSchema: OutputSchema = { fields: createdAutomationFields };
+export const deleteCampaignOutputSchema: OutputSchema = deleted({ idKey: 'campaign_id', idLabel: 'Campaign ID' });
+export const listCampaignLanguagesOutputSchema: OutputSchema = plainList({ label: 'Languages', fields: languageFields });
+export const listFormsOutputSchema: OutputSchema = withAggregations({ schema: pageList({ label: 'Forms', fields: listedFormFields }), keys: ['popup', 'embedded', 'promotion'] });
+export const formOutputSchema: OutputSchema = { fields: formDetailFields };
+export const listFormSubscribersOutputSchema: OutputSchema = cursorList({ label: 'Subscribers', fields: subscriberFields });
+export const listWebhooksOutputSchema: OutputSchema = pageList({ label: 'Webhooks', fields: webhookFields });
+export const webhookOutputSchema: OutputSchema = { fields: webhookFields };
+export const deleteWebhookOutputSchema: OutputSchema = deleted({ idKey: 'webhook_id', idLabel: 'Webhook ID' });
+export const listTimezonesOutputSchema: OutputSchema = plainList({ label: 'Timezones', fields: timezoneFields });
diff --git a/packages/pieces/community/microsoft-outlook/package.json b/packages/pieces/community/microsoft-outlook/package.json
index 86e030e2bd80..03f460986908 100644
--- a/packages/pieces/community/microsoft-outlook/package.json
+++ b/packages/pieces/community/microsoft-outlook/package.json
@@ -1,6 +1,6 @@
{
"name": "@activepieces/piece-microsoft-outlook",
- "version": "0.6.0",
+ "version": "0.6.1",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"dependencies": {
@@ -13,11 +13,13 @@
"@activepieces/core-utils": "workspace:*"
},
"devDependencies": {
- "tslib": "2.6.2"
+ "tslib": "2.6.2",
+ "vitest": "3.2.6"
},
"scripts": {
"build": "tsc -p tsconfig.lib.json && cp package.json dist/",
"bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle",
- "lint": "eslint 'src/**/*.ts'"
+ "lint": "eslint 'src/**/*.ts'",
+ "test": "vitest run"
}
}
diff --git a/packages/pieces/community/microsoft-outlook/src/i18n/translation.json b/packages/pieces/community/microsoft-outlook/src/i18n/translation.json
index 77634267f60e..68e01db41195 100644
--- a/packages/pieces/community/microsoft-outlook/src/i18n/translation.json
+++ b/packages/pieces/community/microsoft-outlook/src/i18n/translation.json
@@ -24,6 +24,50 @@
"Send Draft Email": "Send Draft Email",
"Forward Email": "Forward Email",
"Find Email": "Find Email",
+ "List Messages": "List Messages",
+ "Get Message": "Get Message",
+ "Search Messages": "Search Messages",
+ "Get Message MIME Content": "Get Message MIME Content",
+ "List Message Changes (Delta)": "List Message Changes (Delta)",
+ "Create Draft": "Create Draft",
+ "Send Draft": "Send Draft",
+ "Reply to Message": "Reply to Message",
+ "Reply All to Message": "Reply All to Message",
+ "Forward Message": "Forward Message",
+ "Create Reply Draft": "Create Reply Draft",
+ "Create Reply-All Draft": "Create Reply-All Draft",
+ "Create Forward Draft": "Create Forward Draft",
+ "Update Message": "Update Message",
+ "Mark Message Read / Unread": "Mark Message Read / Unread",
+ "Move Message": "Move Message",
+ "Copy Message": "Copy Message",
+ "Delete Message (to Deleted Items)": "Delete Message (to Deleted Items)",
+ "Permanently Delete Message": "Permanently Delete Message",
+ "Batch Move Messages": "Batch Move Messages",
+ "Batch Update Messages": "Batch Update Messages",
+ "Add Categories to Message": "Add Categories to Message",
+ "Remove Categories from Message": "Remove Categories from Message",
+ "List Message Attachments": "List Message Attachments",
+ "Get Message Attachment": "Get Message Attachment",
+ "Download Message Attachment": "Download Message Attachment",
+ "Add Attachment to Draft": "Add Attachment to Draft",
+ "Delete Message Attachment": "Delete Message Attachment",
+ "List Mail Folders": "List Mail Folders",
+ "List Child Mail Folders": "List Child Mail Folders",
+ "Get Mail Folder": "Get Mail Folder",
+ "Create Mail Folder": "Create Mail Folder",
+ "Rename Mail Folder": "Rename Mail Folder",
+ "Delete Mail Folder": "Delete Mail Folder",
+ "Copy Mail Folder": "Copy Mail Folder",
+ "Move Mail Folder": "Move Mail Folder",
+ "List Folder Changes (Delta)": "List Folder Changes (Delta)",
+ "List Focused Inbox Overrides": "List Focused Inbox Overrides",
+ "Create Focused Inbox Override": "Create Focused Inbox Override",
+ "Update Focused Inbox Override": "Update Focused Inbox Override",
+ "Delete Focused Inbox Override": "Delete Focused Inbox Override",
+ "Get Mail Tips": "Get Mail Tips",
+ "Get My Profile": "Get My Profile",
+ "Get Supported Time Zones": "Get Supported Time Zones",
"Custom API Call": "Custom API Call",
"Sends an email using Microsoft Outlook.": "Sends an email using Microsoft Outlook.",
"Download attachments from a specific email message.": "Download attachments from a specific email message.",
@@ -36,6 +80,51 @@
"Sends a draft email message.": "Sends a draft email message.",
"Forwards an email message.": "Forwards an email message.",
"Searches for emails using full-text search.": "Searches for emails using full-text search.",
+ "Lists messages from the mailbox or a single mail folder.": "Lists messages from the mailbox or a single mail folder.",
+ "Retrieves a single message by its ID.": "Retrieves a single message by its ID.",
+ "Searches messages with a full-text query.": "Searches messages with a full-text query.",
+ "Downloads the raw MIME (RFC-822) content of a message.": "Downloads the raw MIME (RFC-822) content of a message.",
+ "Tracks created, updated and deleted messages in a folder.": "Tracks created, updated and deleted messages in a folder.",
+ "Composes and sends a new email.": "Composes and sends a new email.",
+ "Creates an unsent draft message.": "Creates an unsent draft message.",
+ "Sends an existing draft message.": "Sends an existing draft message.",
+ "Replies to the sender of a message.": "Replies to the sender of a message.",
+ "Replies to the sender and all recipients of a message.": "Replies to the sender and all recipients of a message.",
+ "Forwards a message to new recipients.": "Forwards a message to new recipients.",
+ "Creates an unsent reply draft for a message.": "Creates an unsent reply draft for a message.",
+ "Creates an unsent reply-all draft for a message.": "Creates an unsent reply-all draft for a message.",
+ "Creates an unsent forward draft for a message.": "Creates an unsent forward draft for a message.",
+ "Updates the writable properties of a message.": "Updates the writable properties of a message.",
+ "Marks a message as read or unread.": "Marks a message as read or unread.",
+ "Moves a message to another mail folder.": "Moves a message to another mail folder.",
+ "Copies a message into another mail folder.": "Copies a message into another mail folder.",
+ "Moves a message to the Deleted Items folder.": "Moves a message to the Deleted Items folder.",
+ "Permanently deletes a message so it cannot be recovered.": "Permanently deletes a message so it cannot be recovered.",
+ "Moves several messages to one folder in a single batched call.": "Moves several messages to one folder in a single batched call.",
+ "Applies the same property changes to several messages at once.": "Applies the same property changes to several messages at once.",
+ "Adds categories (labels) to a message.": "Adds categories (labels) to a message.",
+ "Removes categories (labels) from a message.": "Removes categories (labels) from a message.",
+ "Lists the attachments of a message without downloading them.": "Lists the attachments of a message without downloading them.",
+ "Reads the metadata of one attachment.": "Reads the metadata of one attachment.",
+ "Downloads one file attachment of a message.": "Downloads one file attachment of a message.",
+ "Attaches a file to a draft message.": "Attaches a file to a draft message.",
+ "Removes an attachment from a message.": "Removes an attachment from a message.",
+ "Lists the top-level mail folders of the mailbox.": "Lists the top-level mail folders of the mailbox.",
+ "Lists the sub-folders of one mail folder.": "Lists the sub-folders of one mail folder.",
+ "Reads one mail folder by ID or well-known name.": "Reads one mail folder by ID or well-known name.",
+ "Creates a mail folder, or returns the existing one with the same name.": "Creates a mail folder, or returns the existing one with the same name.",
+ "Renames a mail folder.": "Renames a mail folder.",
+ "Deletes a mail folder and everything inside it.": "Deletes a mail folder and everything inside it.",
+ "Copies a mail folder under another folder.": "Copies a mail folder under another folder.",
+ "Moves a mail folder under another folder.": "Moves a mail folder under another folder.",
+ "Tracks created, renamed and deleted mail folders.": "Tracks created, renamed and deleted mail folders.",
+ "Lists the Focused Inbox sender overrides of the mailbox.": "Lists the Focused Inbox sender overrides of the mailbox.",
+ "Forces mail from a sender into the Focused or Other tab.": "Forces mail from a sender into the Focused or Other tab.",
+ "Changes the classification of a Focused Inbox override.": "Changes the classification of a Focused Inbox override.",
+ "Removes a Focused Inbox sender override.": "Removes a Focused Inbox sender override.",
+ "Reads pre-send mail tips for a set of recipients.": "Reads pre-send mail tips for a set of recipients.",
+ "Reads the identity of the connected account.": "Reads the identity of the connected account.",
+ "Lists the time zones Outlook accepts for this account.": "Lists the time zones Outlook accepts for this account.",
"Make a custom API call to a specific endpoint": "Make a custom API call to a specific endpoint",
"To Email(s)": "To Email(s)",
"CC Email(s)": "CC Email(s)",
@@ -49,7 +138,6 @@
"Reply Body": "Reply Body",
"CC Recipients": "CC Recipients",
"BCC Recipients": "BCC Recipients",
- "Create Draft": "Create Draft",
"Categories": "Categories",
"Categories to Remove": "Categories to Remove",
"To Email Address": "To Email Address",
@@ -59,13 +147,38 @@
"Search Query": "Search Query",
"Folder": "Folder",
"Max Results": "Max Results",
+ "Folder ID": "Folder ID",
+ "Filter": "Filter",
+ "Skip": "Skip",
+ "Delta Link": "Delta Link",
+ "Draft Message ID": "Draft Message ID",
+ "Reply Text": "Reply Text",
+ "Additional CC Email(s)": "Additional CC Email(s)",
+ "Read State": "Read State",
+ "Flag State": "Flag State",
+ "Importance": "Importance",
+ "Categories to Add": "Categories to Add",
+ "Destination Folder ID": "Destination Folder ID",
+ "Confirm Permanent Deletion": "Confirm Permanent Deletion",
+ "Message IDs": "Message IDs",
+ "Attachment ID": "Attachment ID",
+ "File": "File",
+ "File Name": "File Name",
+ "Parent Folder ID": "Parent Folder ID",
+ "Folder Name": "Folder Name",
+ "New Folder Name": "New Folder Name",
+ "Sender Email Address": "Sender Email Address",
+ "Classify As": "Classify As",
+ "Sender Display Name": "Sender Display Name",
+ "Override ID": "Override ID",
+ "Recipient Email Addresses": "Recipient Email Addresses",
"Method": "Method",
"Headers": "Headers",
"Query Parameters": "Query Parameters",
"Body Type": "Body Type",
- "Response is Binary ?": "Response is Binary ?",
- "No Error on Failure": "No Error on Failure",
- "Timeout (in seconds)": "Timeout (in seconds)",
+ "Response is Binary": "Response is Binary",
+ "Return Error as Output": "Return Error as Output",
+ "Timeout": "Timeout",
"Follow redirects": "Follow redirects",
"The ID of the email message containing the attachment.": "The ID of the email message containing the attachment.",
"Select the email message to reply to.": "Select the email message to reply to.",
@@ -85,20 +198,81 @@
"Search terms to find emails (e.g., \"from:john@example.com\", \"subject:urgent\", \"hasAttachments:true\")": "Search terms to find emails (e.g., \"from:john@example.com\", \"subject:urgent\", \"hasAttachments:true\")",
"Search in a specific folder. Leave empty to search all folders.": "Search in a specific folder. Leave empty to search all folders.",
"Maximum number of results to return (1-1000).": "Maximum number of results to return (1-1000).",
+ "Limit the listing to one folder. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to list the whole mailbox.": "Limit the listing to one folder. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to list the whole mailbox.",
+ "Optional OData $filter expression, for example: isRead eq false, or receivedDateTime ge 2024-01-01T00:00:00Z.": "Optional OData $filter expression, for example: isRead eq false, or receivedDateTime ge 2024-01-01T00:00:00Z.",
+ "Maximum number of messages to return (1-100).": "Maximum number of messages to return (1-100).",
+ "Number of messages to skip before returning results, for paging.": "Number of messages to skip before returning results, for paging.",
+ "Outlook message ID. Resolve one with List Messages, Search Messages or Get Message.": "Outlook message ID. Resolve one with List Messages, Search Messages or Get Message.",
+ "Keywords or KQL terms to match, for example: from:john@example.com, subject:urgent, hasAttachments:true.": "Keywords or KQL terms to match, for example: from:john@example.com, subject:urgent, hasAttachments:true.",
+ "Search inside one folder only. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to search the whole mailbox.": "Search inside one folder only. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to search the whole mailbox.",
+ "Folder to track. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.": "Folder to track. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.",
+ "The deltaLink returned by a previous run. Leave empty to start a new sync from the current folder state.": "The deltaLink returned by a previous run. Leave empty to start a new sync from the current folder state.",
+ "Email addresses of the primary recipients.": "Email addresses of the primary recipients.",
+ "Files to attach. Total message size must stay under 3 MB.": "Files to attach. Total message size must stay under 3 MB.",
+ "Create the draft inside a specific folder. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to use the Drafts folder.": "Create the draft inside a specific folder. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to use the Drafts folder.",
+ "ID of the draft to send. Obtain it from Create Draft or one of the Create ... Draft actions.": "ID of the draft to send. Obtain it from Create Draft or one of the Create ... Draft actions.",
+ "Text added above the quoted original message.": "Text added above the quoted original message.",
+ "Email addresses to forward the message to.": "Email addresses to forward the message to.",
+ "Optional text added above the forwarded message.": "Optional text added above the forwarded message.",
+ "Optional text added above the quoted original message.": "Optional text added above the quoted original message.",
+ "Email addresses the draft will forward the message to.": "Email addresses the draft will forward the message to.",
+ "Leave unset to keep the current read state.": "Leave unset to keep the current read state.",
+ "Leave unset to keep the current flag.": "Leave unset to keep the current flag.",
+ "Leave unset to keep the current importance.": "Leave unset to keep the current importance.",
+ "Category names to add, merged with the categories already on the message.": "Category names to add, merged with the categories already on the message.",
+ "Category names to remove from the message.": "Category names to remove from the message.",
+ "Only writable while the message is still a draft.": "Only writable while the message is still a draft.",
+ "Format of the new body. Ignored when no body is supplied.": "Format of the new body. Ignored when no body is supplied.",
+ "Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.": "Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.",
+ "Must be enabled. The message cannot be recovered afterwards.": "Must be enabled. The message cannot be recovered afterwards.",
+ "IDs of the messages to move. Resolve them with List Messages or Search Messages.": "IDs of the messages to move. Resolve them with List Messages or Search Messages.",
+ "IDs of the messages to update. Resolve them with List Messages or Search Messages.": "IDs of the messages to update. Resolve them with List Messages or Search Messages.",
+ "Leave unset to keep each message current read state.": "Leave unset to keep each message current read state.",
+ "Leave unset to keep each message current flag.": "Leave unset to keep each message current flag.",
+ "Leave unset to keep each message current importance.": "Leave unset to keep each message current importance.",
+ "Category names merged into the categories each message already has.": "Category names merged into the categories each message already has.",
+ "Category names removed from each message.": "Category names removed from each message.",
+ "Category names to add to the message.": "Category names to add to the message.",
+ "Attachment ID from List Message Attachments. Attachment IDs are only valid inside their own message.": "Attachment ID from List Message Attachments. Attachment IDs are only valid inside their own message.",
+ "ID of the draft to attach the file to. Obtain it from Create Draft.": "ID of the draft to attach the file to. Obtain it from Create Draft.",
+ "File to attach. Must be smaller than 3 MB.": "File to attach. Must be smaller than 3 MB.",
+ "Name shown on the attachment. Defaults to the uploaded file name.": "Name shown on the attachment. Defaults to the uploaded file name.",
+ "Attachment ID from List Message Attachments.": "Attachment ID from List Message Attachments.",
+ "Maximum number of folders to return (1-100).": "Maximum number of folders to return (1-100).",
+ "Display name of the folder to create.": "Display name of the folder to create.",
+ "Create the folder under this parent. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to create it at the top level.": "Create the folder under this parent. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Leave empty to create it at the top level.",
+ "Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Well-known system folders cannot be deleted.": "Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail. Well-known system folders cannot be deleted.",
+ "Folder that will contain the copy. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.": "Folder that will contain the copy. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.",
+ "New parent folder. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.": "New parent folder. Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.",
+ "The deltaLink returned by a previous run. Leave empty to start a new sync from the current folder tree.": "The deltaLink returned by a previous run. Leave empty to start a new sync from the current folder tree.",
+ "SMTP address of the sender the override applies to.": "SMTP address of the sender the override applies to.",
+ "Optional display name stored alongside the override.": "Optional display name stored alongside the override.",
+ "Override ID from List Focused Inbox Overrides.": "Override ID from List Focused Inbox Overrides.",
+ "Addresses to check. Up to 100 recipients per call.": "Addresses to check. Up to 100 recipients per call.",
"Authorization headers are injected automatically from your connection.": "Authorization headers are injected automatically from your connection.",
+ "Appended to the URL as ?key=value.": "Appended to the URL as ?key=value.",
"Enable for files like PDFs, images, etc.": "Enable for files like PDFs, images, etc.",
+ "On a failed request, output the error instead of failing the step.": "On a failed request, output the error instead of failing the step.",
+ "Seconds to wait for a response. Empty: up to the flow limit (10 min).": "Seconds to wait for a response. Empty: up to the flow limit (10 min).",
+ "Follow 3xx redirects instead of returning them as the response.": "Follow 3xx redirects instead of returning them as the response.",
"HTML": "HTML",
"Text": "Text",
+ "Mark as read": "Mark as read",
+ "Mark as unread": "Mark as unread",
+ "Flagged": "Flagged",
+ "Not flagged": "Not flagged",
+ "Complete": "Complete",
+ "Low": "Low",
+ "Normal": "Normal",
+ "High": "High",
+ "Focused": "Focused",
+ "Other": "Other",
"GET": "GET",
"POST": "POST",
"PATCH": "PATCH",
"PUT": "PUT",
"DELETE": "DELETE",
"HEAD": "HEAD",
- "None": "None",
- "JSON": "JSON",
- "Form Data": "Form Data",
- "Raw": "Raw",
"New Email": "New Email",
"New Email in Folder": "New Email in Folder",
"New Attachment": "New Attachment",
diff --git a/packages/pieces/community/microsoft-outlook/src/index.ts b/packages/pieces/community/microsoft-outlook/src/index.ts
index 7fab2aa50952..30c48cf718d6 100644
--- a/packages/pieces/community/microsoft-outlook/src/index.ts
+++ b/packages/pieces/community/microsoft-outlook/src/index.ts
@@ -17,6 +17,51 @@ import { newAttachmentTrigger } from './lib/triggers/new-attachment';
import { newEmailInFolderTrigger } from './lib/triggers/new-email-in-folder';
import { newEmailTrigger } from './lib/triggers/new-email';
import { requestApprovalInMail } from './lib/actions/request-approval-send-email';
+import { outlookAddMessageAttachmentAction } from './lib/actions/outlook-add-message-attachment';
+import { outlookAddMessageCategoriesAction } from './lib/actions/outlook-add-message-categories';
+import { outlookBatchMoveMessagesAction } from './lib/actions/outlook-batch-move-messages';
+import { outlookBatchUpdateMessagesAction } from './lib/actions/outlook-batch-update-messages';
+import { outlookCopyMailFolderAction } from './lib/actions/outlook-copy-mail-folder';
+import { outlookCopyMessageAction } from './lib/actions/outlook-copy-message';
+import { outlookCreateDraftAction } from './lib/actions/outlook-create-draft';
+import { outlookCreateFocusedInboxOverrideAction } from './lib/actions/outlook-create-focused-inbox-override';
+import { outlookCreateForwardDraftAction } from './lib/actions/outlook-create-forward-draft';
+import { outlookCreateMailFolderAction } from './lib/actions/outlook-create-mail-folder';
+import { outlookCreateReplyAllDraftAction } from './lib/actions/outlook-create-reply-all-draft';
+import { outlookCreateReplyDraftAction } from './lib/actions/outlook-create-reply-draft';
+import { outlookDeleteFocusedInboxOverrideAction } from './lib/actions/outlook-delete-focused-inbox-override';
+import { outlookDeleteMailFolderAction } from './lib/actions/outlook-delete-mail-folder';
+import { outlookDeleteMessageAction } from './lib/actions/outlook-delete-message';
+import { outlookDeleteMessageAttachmentAction } from './lib/actions/outlook-delete-message-attachment';
+import { outlookDownloadMessageAttachmentAction } from './lib/actions/outlook-download-message-attachment';
+import { outlookForwardMessageAction } from './lib/actions/outlook-forward-message';
+import { outlookGetMailFolderAction } from './lib/actions/outlook-get-mail-folder';
+import { outlookGetMailTipsAction } from './lib/actions/outlook-get-mail-tips';
+import { outlookGetMessageAction } from './lib/actions/outlook-get-message';
+import { outlookGetMessageAttachmentAction } from './lib/actions/outlook-get-message-attachment';
+import { outlookGetMessageMimeAction } from './lib/actions/outlook-get-message-mime';
+import { outlookGetMyProfileAction } from './lib/actions/outlook-get-my-profile';
+import { outlookGetSupportedTimeZonesAction } from './lib/actions/outlook-get-supported-time-zones';
+import { outlookListChildMailFoldersAction } from './lib/actions/outlook-list-child-mail-folders';
+import { outlookListFocusedInboxOverridesAction } from './lib/actions/outlook-list-focused-inbox-overrides';
+import { outlookListMailFoldersAction } from './lib/actions/outlook-list-mail-folders';
+import { outlookListMailFoldersDeltaAction } from './lib/actions/outlook-list-mail-folders-delta';
+import { outlookListMessageAttachmentsAction } from './lib/actions/outlook-list-message-attachments';
+import { outlookListMessagesAction } from './lib/actions/outlook-list-messages';
+import { outlookListMessagesDeltaAction } from './lib/actions/outlook-list-messages-delta';
+import { outlookMoveMailFolderAction } from './lib/actions/outlook-move-mail-folder';
+import { outlookMoveMessageAction } from './lib/actions/outlook-move-message';
+import { outlookPermanentlyDeleteMessageAction } from './lib/actions/outlook-permanently-delete-message';
+import { outlookRemoveMessageCategoriesAction } from './lib/actions/outlook-remove-message-categories';
+import { outlookReplyAllToMessageAction } from './lib/actions/outlook-reply-all-to-message';
+import { outlookReplyToMessageAction } from './lib/actions/outlook-reply-to-message';
+import { outlookSearchMessagesAction } from './lib/actions/outlook-search-messages';
+import { outlookSendDraftAction } from './lib/actions/outlook-send-draft';
+import { outlookSendEmailAction } from './lib/actions/outlook-send-email';
+import { outlookSetMessageReadStatusAction } from './lib/actions/outlook-set-message-read-status';
+import { outlookUpdateFocusedInboxOverrideAction } from './lib/actions/outlook-update-focused-inbox-override';
+import { outlookUpdateMailFolderAction } from './lib/actions/outlook-update-mail-folder';
+import { outlookUpdateMessageAction } from './lib/actions/outlook-update-message';
export const microsoftOutlook = createPiece({
displayName: 'Microsoft Outlook',
@@ -37,6 +82,51 @@ export const microsoftOutlook = createPiece({
sendDraftEmailAction,
forwardEmailAction,
findEmailAction,
+ outlookListMessagesAction,
+ outlookGetMessageAction,
+ outlookSearchMessagesAction,
+ outlookGetMessageMimeAction,
+ outlookListMessagesDeltaAction,
+ outlookSendEmailAction,
+ outlookCreateDraftAction,
+ outlookSendDraftAction,
+ outlookReplyToMessageAction,
+ outlookReplyAllToMessageAction,
+ outlookForwardMessageAction,
+ outlookCreateReplyDraftAction,
+ outlookCreateReplyAllDraftAction,
+ outlookCreateForwardDraftAction,
+ outlookUpdateMessageAction,
+ outlookSetMessageReadStatusAction,
+ outlookMoveMessageAction,
+ outlookCopyMessageAction,
+ outlookDeleteMessageAction,
+ outlookPermanentlyDeleteMessageAction,
+ outlookBatchMoveMessagesAction,
+ outlookBatchUpdateMessagesAction,
+ outlookAddMessageCategoriesAction,
+ outlookRemoveMessageCategoriesAction,
+ outlookListMessageAttachmentsAction,
+ outlookGetMessageAttachmentAction,
+ outlookDownloadMessageAttachmentAction,
+ outlookAddMessageAttachmentAction,
+ outlookDeleteMessageAttachmentAction,
+ outlookListMailFoldersAction,
+ outlookListChildMailFoldersAction,
+ outlookGetMailFolderAction,
+ outlookCreateMailFolderAction,
+ outlookUpdateMailFolderAction,
+ outlookDeleteMailFolderAction,
+ outlookCopyMailFolderAction,
+ outlookMoveMailFolderAction,
+ outlookListMailFoldersDeltaAction,
+ outlookListFocusedInboxOverridesAction,
+ outlookCreateFocusedInboxOverrideAction,
+ outlookUpdateFocusedInboxOverrideAction,
+ outlookDeleteFocusedInboxOverrideAction,
+ outlookGetMailTipsAction,
+ outlookGetMyProfileAction,
+ outlookGetSupportedTimeZonesAction,
createCustomApiCallAction({
auth: microsoftOutlookAuth,
baseUrl: (auth) => {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts
index 15a8397f26ec..da396b834707 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/add-label-to-email.ts
@@ -10,7 +10,7 @@ export const addLabelToEmailAction = createAction({
classification: 'WRITE',
displayName: 'Add Label to Email',
description: 'Adds a category (label) to an email message.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Adds one or more Outlook categories (labels) to a specific message, merging them with any categories already present. Use this to tag or classify an email. Idempotent: re-adding the same categories leaves the message unchanged since duplicates are de-duplicated.', idempotent: true },
outputSchema: messageActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/batch-output-shape.test.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/batch-output-shape.test.ts
new file mode 100644
index 000000000000..493bd8048d2a
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/batch-output-shape.test.ts
@@ -0,0 +1,131 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const { runGraphBatchMock } = vi.hoisted(() => ({ runGraphBatchMock: vi.fn() }));
+
+vi.mock('../common/graph-batch', async () => {
+ const actual = await vi.importActual(
+ '../common/graph-batch',
+ );
+ return { ...actual, runGraphBatch: runGraphBatchMock };
+});
+
+vi.mock('../common/client', () => ({
+ outlookCommon: {
+ createClient: () => ({}),
+ mailboxPrefix: () => '/me',
+ },
+}));
+
+import { outlookBatchMoveMessagesAction } from './outlook-batch-move-messages';
+import { outlookBatchUpdateMessagesAction } from './outlook-batch-update-messages';
+
+const auth = { access_token: 'token' } as never;
+
+function run(action: { run: (context: never) => Promise }, propsValue: unknown) {
+ return action.run({ auth, propsValue } as never);
+}
+
+describe('outlook batch actions share one output contract', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('batch_update_messages returns succeeded as objects carrying messageId', async () => {
+ runGraphBatchMock.mockResolvedValueOnce([
+ { id: '1', status: 200, body: { id: 'm1' } },
+ { id: '2', status: 200, body: { id: 'm2' } },
+ ]);
+
+ const result = (await run(outlookBatchUpdateMessagesAction, {
+ messageIds: ['m1', 'm2'],
+ isRead: 'true',
+ })) as Record;
+
+ expect(result['succeeded']).toEqual([{ messageId: 'm1' }, { messageId: 'm2' }]);
+ expect(result['success']).toBe(true);
+ expect(result['requestedCount']).toBe(2);
+ expect(result['succeededCount']).toBe(2);
+ expect(result['failedCount']).toBe(0);
+ expect(result['failed']).toEqual([]);
+ });
+
+ it('batch_move_messages returns succeeded as objects carrying messageId', async () => {
+ runGraphBatchMock.mockResolvedValueOnce([
+ { id: '1', status: 201, body: { id: 'new-1' } },
+ ]);
+
+ const result = (await run(outlookBatchMoveMessagesAction, {
+ messageIds: ['m1'],
+ destinationFolderId: 'archive',
+ })) as Record;
+
+ expect(result['succeeded']).toEqual([{ messageId: 'm1', newMessageId: 'new-1' }]);
+ expect(result['success']).toBe(true);
+ expect(result['succeededCount']).toBe(1);
+ });
+
+ it('every succeeded entry of both actions is an object with a messageId', async () => {
+ runGraphBatchMock.mockResolvedValueOnce([{ id: '1', status: 200, body: { id: 'm1' } }]);
+ const updated = (await run(outlookBatchUpdateMessagesAction, {
+ messageIds: ['m1'],
+ isRead: 'true',
+ })) as { succeeded: Array> };
+
+ runGraphBatchMock.mockResolvedValueOnce([{ id: '1', status: 201, body: { id: 'new-1' } }]);
+ const moved = (await run(outlookBatchMoveMessagesAction, {
+ messageIds: ['m1'],
+ destinationFolderId: 'archive',
+ })) as { succeeded: Array> };
+
+ for (const entry of [...updated.succeeded, ...moved.succeeded]) {
+ expect(typeof entry).toBe('object');
+ expect(typeof entry['messageId']).toBe('string');
+ }
+ });
+
+ it('reports partial failure identically on both actions', async () => {
+ const mixed = [
+ { id: '1', status: 200, body: { id: 'm1' } },
+ { id: '2', status: 404, body: { error: { code: 'ErrorItemNotFound', message: 'gone' } } },
+ ];
+
+ runGraphBatchMock.mockResolvedValueOnce(mixed);
+ const updated = (await run(outlookBatchUpdateMessagesAction, {
+ messageIds: ['m1', 'm2'],
+ isRead: 'true',
+ })) as Record;
+
+ runGraphBatchMock.mockResolvedValueOnce(mixed);
+ const moved = (await run(outlookBatchMoveMessagesAction, {
+ messageIds: ['m1', 'm2'],
+ destinationFolderId: 'archive',
+ })) as Record;
+
+ for (const result of [updated, moved]) {
+ expect(result['success']).toBe(false);
+ expect(result['succeededCount']).toBe(1);
+ expect(result['failedCount']).toBe(1);
+ expect(result['failed']).toEqual([
+ { messageId: 'm2', status: 404, error: 'ErrorItemNotFound: gone' },
+ ]);
+ }
+ });
+
+ it('merges categories per message before patching', async () => {
+ runGraphBatchMock
+ .mockResolvedValueOnce([
+ { id: '1', status: 200, body: { id: 'm1', categories: ['keep'] } },
+ ])
+ .mockResolvedValueOnce([{ id: '1', status: 200, body: { id: 'm1' } }]);
+
+ await run(outlookBatchUpdateMessagesAction, {
+ messageIds: ['m1'],
+ categoriesToAdd: ['added'],
+ });
+
+ const patchCall = runGraphBatchMock.mock.calls[1][0] as {
+ requests: Array<{ body: { categories: string[] } }>;
+ };
+ expect(patchCall.requests[0].body.categories).toEqual(['keep', 'added']);
+ });
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts
index 4fa22b2a2d67..b2369ee66e34 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/create-draft-email.ts
@@ -10,7 +10,7 @@ export const createDraftEmailAction = createAction({
classification: 'WRITE',
displayName: 'Create Draft Email',
description: 'Creates a draft email message.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Creates a new unsent draft email in the Outlook mailbox with recipients, subject, body, and optional attachments. Use this to stage a message for later review or sending (pair with Send Draft Email). Not idempotent: each call creates a separate draft.', idempotent: false },
outputSchema: draftMessageActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts
index aaf7b1ac77b8..15a9f3d012e8 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/find-email.ts
@@ -13,7 +13,7 @@ export const findEmailAction = createAction({
classification: 'SEARCH',
displayName: 'Find Email',
description: 'Searches for emails using full-text search.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Searches the Outlook mailbox for messages matching a full-text query (supports field syntax like from:, subject:, hasAttachments:), optionally scoped to one folder and capped by a max-results count. Use this to locate emails and obtain their message IDs for follow-up actions. Idempotent read-only lookup.', idempotent: true },
outputSchema: findEmailActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts
index a0efa57fd836..004d5b358006 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/forward-email.ts
@@ -11,7 +11,7 @@ export const forwardEmailAction = createAction({
classification: 'WRITE',
displayName: 'Forward Email',
description: 'Forwards an email message.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Forwards an existing Outlook message (by message ID) to new recipients, preserving the original body and attachments and prepending an optional comment. Use this to pass an existing email along rather than composing a new one. Not idempotent: each call sends a new forwarded email.', idempotent: false },
outputSchema: forwardEmailActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts
index b34525f3df4e..75a0635c4b30 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/move-email-to-folder.ts
@@ -10,7 +10,7 @@ export const moveEmailToFolderAction = createAction({
classification: 'WRITE',
displayName: 'Move Email to Folder',
description: 'Moves an email message to a specific folder.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Moves a specific Outlook message into a chosen mail folder. Use this to organize, archive, or route an email after processing it. Note: the move assigns a new message ID, so re-running with the original ID will fail once moved.', idempotent: false },
outputSchema: messageActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-add-message-attachment.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-add-message-attachment.ts
new file mode 100644
index 000000000000..49eccf80d408
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-add-message-attachment.ts
@@ -0,0 +1,72 @@
+import { ApFile, createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookAddMessageAttachmentActionOutputSchema } from '../output-schemas';
+
+export const outlookAddMessageAttachmentAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_add_message_attachment',
+ classification: 'WRITE',
+ displayName: 'Add Attachment to Draft',
+ description: 'Attaches a file to a draft message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Attaches one file to an existing draft message and returns the created attachment metadata without echoing the file bytes back. Only meaningful on drafts, and only for files under 3 MB; larger files need an upload session, which this piece does not expose yet. Pair it with Create Draft and Send Draft. Not idempotent: each call adds another attachment, even with the same file name.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Draft Message ID',
+ description: 'ID of the draft to attach the file to. Obtain it from Create Draft.',
+ required: true,
+ }),
+ file: Property.File({
+ displayName: 'File',
+ description: 'File to attach. Must be smaller than 3 MB.',
+ required: true,
+ }),
+ fileName: Property.ShortText({
+ displayName: 'File Name',
+ description: 'Name shown on the attachment. Defaults to the uploaded file name.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookAddMessageAttachmentActionOutputSchema,
+ async run(context) {
+ const { messageId, fileName } = context.propsValue;
+ const file = context.propsValue.file as ApFile;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const attachment = await client
+ .api(
+ `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/attachments`,
+ )
+ .post({
+ '@odata.type': '#microsoft.graph.fileAttachment',
+ name: fileName || file.filename,
+ contentBytes: file.base64,
+ });
+
+ return {
+ messageId,
+ id: attachment?.['id'] ?? null,
+ name: attachment?.['name'] ?? null,
+ contentType: attachment?.['contentType'] ?? null,
+ size: attachment?.['size'] ?? null,
+ isInline: attachment?.['isInline'] ?? null,
+ lastModifiedDateTime: attachment?.['lastModifiedDateTime'] ?? null,
+ attachmentType: attachment?.['@odata.type'] ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Adding the attachment to the Outlook draft',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-add-message-categories.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-add-message-categories.ts
new file mode 100644
index 000000000000..4b2bba5302d9
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-add-message-categories.ts
@@ -0,0 +1,56 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookAddMessageCategoriesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_add_message_categories',
+ classification: 'WRITE',
+ displayName: 'Add Categories to Message',
+ description: 'Adds categories (labels) to a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Adds one or more Outlook categories to a message, merging them with the categories already on it so nothing is overwritten. Use Remove Categories from Message for the inverse, and Update Message when other fields change too. Idempotent: re-adding the same categories leaves the message unchanged.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ categories: Property.Array({
+ displayName: 'Categories',
+ description: 'Category names to add to the message.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookMessageActionOutputSchema,
+ async run(context) {
+ const { messageId } = context.propsValue;
+ const categories = context.propsValue.categories as string[];
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const path = `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}`;
+
+ try {
+ const current = await client.api(`${path}?$select=id,categories`).get();
+ const existing = (current?.['categories'] ?? []) as string[];
+ const merged = [...new Set([...existing, ...categories])];
+
+ return await client
+ .api(path)
+ .headers(outlookAtomicCommon.textBodyHeaders)
+ .patch({ categories: merged });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Adding categories to the Outlook message',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-batch-move-messages.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-batch-move-messages.ts
new file mode 100644
index 000000000000..c4db86854393
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-batch-move-messages.ts
@@ -0,0 +1,85 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { describeBatchFailure, GraphBatchRequest, runGraphBatch } from '../common/graph-batch';
+import { outlookBatchMoveMessagesActionOutputSchema } from '../output-schemas';
+
+export const outlookBatchMoveMessagesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_batch_move_messages',
+ classification: 'WRITE',
+ displayName: 'Batch Move Messages',
+ description: 'Moves several messages to one folder in a single batched call.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Moves many messages into the same destination folder using Microsoft Graph batching, in chunks of 20 sub-requests. Every moved message gets a NEW ID, returned per item, and the original IDs stop resolving. Individual items can fail while others succeed, so always read the failed list. Use Move Message for a single message. Not idempotent: a retry with the old IDs fails.',
+ idempotent: false,
+ },
+ props: {
+ messageIds: Property.Array({
+ displayName: 'Message IDs',
+ description: 'IDs of the messages to move. Resolve them with List Messages or Search Messages.',
+ required: true,
+ }),
+ destinationFolderId: Property.ShortText({
+ displayName: 'Destination Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ }),
+ },
+ outputSchema: outlookBatchMoveMessagesActionOutputSchema,
+ async run(context) {
+ const { destinationFolderId } = context.propsValue;
+ const messageIds = (context.propsValue.messageIds ?? []) as string[];
+
+ if (messageIds.length === 0) {
+ throw new Error('Batch moving Outlook messages failed: no message IDs were supplied.');
+ }
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ const requests: GraphBatchRequest[] = messageIds.map((messageId, index) => ({
+ id: String(index + 1),
+ method: 'POST',
+ url: `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/move`,
+ headers: { 'Content-Type': 'application/json' },
+ body: { destinationId: destinationFolderId },
+ }));
+
+ try {
+ const responses = await runGraphBatch({ client, requests });
+
+ const succeeded: Array<{ messageId: string; newMessageId: string | null }> = [];
+ const failed: Array<{ messageId: string; status: number; error: string }> = [];
+
+ for (const response of responses) {
+ const messageId = messageIds[Number(response.id) - 1];
+ if (response.status >= 200 && response.status < 300) {
+ const body = (response.body ?? {}) as { id?: string };
+ succeeded.push({ messageId, newMessageId: body.id ?? null });
+ } else {
+ failed.push({
+ messageId,
+ status: response.status,
+ error: describeBatchFailure(response),
+ });
+ }
+ }
+
+ return {
+ success: failed.length === 0,
+ destinationFolderId,
+ requestedCount: messageIds.length,
+ succeededCount: succeeded.length,
+ failedCount: failed.length,
+ succeeded,
+ failed,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Batch moving Outlook messages' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-batch-update-messages.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-batch-update-messages.ts
new file mode 100644
index 000000000000..3aec2fa888b0
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-batch-update-messages.ts
@@ -0,0 +1,190 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { FollowupFlagStatus, Importance, Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { describeBatchFailure, GraphBatchRequest, runGraphBatch } from '../common/graph-batch';
+import { outlookBatchUpdateMessagesActionOutputSchema } from '../output-schemas';
+
+export const outlookBatchUpdateMessagesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_batch_update_messages',
+ classification: 'WRITE',
+ displayName: 'Batch Update Messages',
+ description: 'Applies the same property changes to several messages at once.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Applies the same read state, flag, importance and category changes to many messages through Microsoft Graph batching, in chunks of 20 sub-requests. Fields left empty are never sent, and categories are merged per message against that message current categories, so tags on other messages are not wiped. Individual items can fail while others succeed, so always read the failed list. Idempotent: re-applying the same field values converges on the same state and creates no new entities.',
+ idempotent: true,
+ },
+ props: {
+ messageIds: Property.Array({
+ displayName: 'Message IDs',
+ description: 'IDs of the messages to update. Resolve them with List Messages or Search Messages.',
+ required: true,
+ }),
+ isRead: Property.StaticDropdown({
+ displayName: 'Read State',
+ description: 'Leave unset to keep each message current read state.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Mark as read', value: 'true' },
+ { label: 'Mark as unread', value: 'false' },
+ ],
+ },
+ }),
+ flagStatus: Property.StaticDropdown({
+ displayName: 'Flag State',
+ description: 'Leave unset to keep each message current flag.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Flagged', value: 'flagged' },
+ { label: 'Not flagged', value: 'notFlagged' },
+ { label: 'Complete', value: 'complete' },
+ ],
+ },
+ }),
+ importance: Property.StaticDropdown({
+ displayName: 'Importance',
+ description: 'Leave unset to keep each message current importance.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Low', value: 'low' },
+ { label: 'Normal', value: 'normal' },
+ { label: 'High', value: 'high' },
+ ],
+ },
+ }),
+ categoriesToAdd: Property.Array({
+ displayName: 'Categories to Add',
+ description: 'Category names merged into the categories each message already has.',
+ required: false,
+ }),
+ categoriesToRemove: Property.Array({
+ displayName: 'Categories to Remove',
+ description: 'Category names removed from each message.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookBatchUpdateMessagesActionOutputSchema,
+ async run(context) {
+ const { isRead, flagStatus, importance } = context.propsValue;
+ const messageIds = (context.propsValue.messageIds ?? []) as string[];
+ const categoriesToAdd = (context.propsValue.categoriesToAdd ?? []) as string[];
+ const categoriesToRemove = (context.propsValue.categoriesToRemove ?? []) as string[];
+
+ if (messageIds.length === 0) {
+ throw new Error('Batch updating Outlook messages failed: no message IDs were supplied.');
+ }
+
+ const basePayload: Message = {};
+ if (isRead !== undefined) {
+ basePayload.isRead = isRead === 'true';
+ }
+ if (flagStatus !== undefined) {
+ basePayload.flag = { flagStatus: flagStatus as FollowupFlagStatus };
+ }
+ if (importance !== undefined) {
+ basePayload.importance = importance as Importance;
+ }
+
+ const changesCategories = categoriesToAdd.length > 0 || categoriesToRemove.length > 0;
+
+ if (Object.keys(basePayload).length === 0 && !changesCategories) {
+ throw new Error(
+ 'Batch updating Outlook messages failed: no fields were supplied, so there is nothing to change.',
+ );
+ }
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ const failed: Array<{ messageId: string; status: number; error: string }> = [];
+ const mergedCategories = new Map();
+
+ try {
+ if (changesCategories) {
+ const readRequests: GraphBatchRequest[] = messageIds.map((messageId, index) => ({
+ id: String(index + 1),
+ method: 'GET',
+ url: `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(
+ messageId,
+ )}?$select=id,categories`,
+ }));
+
+ const readResponses = await runGraphBatch({ client, requests: readRequests });
+
+ for (const response of readResponses) {
+ const messageId = messageIds[Number(response.id) - 1];
+ if (response.status >= 200 && response.status < 300) {
+ const body = (response.body ?? {}) as { categories?: string[] };
+ const existing = body.categories ?? [];
+ mergedCategories.set(
+ messageId,
+ [...new Set([...existing, ...categoriesToAdd])].filter(
+ (category) => !categoriesToRemove.includes(category),
+ ),
+ );
+ } else {
+ failed.push({
+ messageId,
+ status: response.status,
+ error: describeBatchFailure(response),
+ });
+ }
+ }
+ }
+
+ const targets = changesCategories
+ ? messageIds.filter((messageId) => mergedCategories.has(messageId))
+ : messageIds;
+
+ const patchRequests: GraphBatchRequest[] = targets.map((messageId, index) => ({
+ id: String(index + 1),
+ method: 'PATCH',
+ url: `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}`,
+ headers: { 'Content-Type': 'application/json' },
+ body: changesCategories
+ ? { ...basePayload, categories: mergedCategories.get(messageId) }
+ : { ...basePayload },
+ }));
+
+ const succeeded: Array<{ messageId: string }> = [];
+
+ if (patchRequests.length > 0) {
+ const patchResponses = await runGraphBatch({ client, requests: patchRequests });
+
+ for (const response of patchResponses) {
+ const messageId = targets[Number(response.id) - 1];
+ if (response.status >= 200 && response.status < 300) {
+ succeeded.push({ messageId });
+ } else {
+ failed.push({
+ messageId,
+ status: response.status,
+ error: describeBatchFailure(response),
+ });
+ }
+ }
+ }
+
+ return {
+ success: failed.length === 0,
+ requestedCount: messageIds.length,
+ succeededCount: succeeded.length,
+ failedCount: failed.length,
+ succeeded,
+ failed,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Batch updating Outlook messages' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-copy-mail-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-copy-mail-folder.ts
new file mode 100644
index 000000000000..73bd958e6ec1
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-copy-mail-folder.ts
@@ -0,0 +1,58 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { MailFolder } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookCopyMailFolderActionOutputSchema } from '../output-schemas';
+
+export const outlookCopyMailFolderAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_copy_mail_folder',
+ classification: 'WRITE',
+ displayName: 'Copy Mail Folder',
+ description: 'Copies a mail folder under another folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Copies a mail folder, with its messages and sub-folders, under a destination folder and returns the new folder. Use Move Mail Folder when the original should not stay in place. Not idempotent: each call creates another copy.',
+ idempotent: false,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ }),
+ destinationFolderId: Property.ShortText({
+ displayName: 'Destination Folder ID',
+ description: `Folder that will contain the copy. ${outlookAtomicCommon.wellKnownFolderHint}`,
+ required: true,
+ }),
+ },
+ outputSchema: outlookCopyMailFolderActionOutputSchema,
+ async run(context) {
+ const { folderId, destinationFolderId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const copied: MailFolder = await client
+ .api(`${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}/copy`)
+ .post({ destinationId: destinationFolderId });
+
+ return {
+ success: true,
+ sourceFolderId: folderId,
+ newFolderId: copied?.id ?? null,
+ destinationFolderId,
+ folder: copied,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Copying the Outlook mail folder',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-copy-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-copy-message.ts
new file mode 100644
index 000000000000..db8e39aefe81
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-copy-message.ts
@@ -0,0 +1,55 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookCopyMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookCopyMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_copy_message',
+ classification: 'WRITE',
+ displayName: 'Copy Message',
+ description: 'Copies a message into another mail folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Copies one message into another mail folder, leaving the original where it is, and returns the copy. The copy has a NEW message ID, returned as newMessageId. Use Move Message when the original should not stay behind. Not idempotent: each call creates one more copy.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ destinationFolderId: Property.ShortText({
+ displayName: 'Destination Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ }),
+ },
+ outputSchema: outlookCopyMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, destinationFolderId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const copied: Message = await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/copy`)
+ .post({ destinationId: destinationFolderId });
+
+ return {
+ success: true,
+ sourceMessageId: messageId,
+ newMessageId: copied?.id ?? null,
+ destinationFolderId,
+ message: copied,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Copying the Outlook message' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-draft.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-draft.ts
new file mode 100644
index 000000000000..661a017d83bc
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-draft.ts
@@ -0,0 +1,114 @@
+import { ApFile, createAction, Property } from '@activepieces/pieces-framework';
+import { BodyType, Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDraftMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookCreateDraftAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_create_draft',
+ classification: 'WRITE',
+ displayName: 'Create Draft',
+ description: 'Creates an unsent draft message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Creates an unsent draft email in the mailbox (optionally inside a specific folder) and returns its message ID. Use this to stage a message for review, to attach files with Add Attachment to Draft, or before Send Draft; use Send Email to send in one step. Not idempotent: every call creates another draft.',
+ idempotent: false,
+ },
+ props: {
+ recipients: Property.Array({
+ displayName: 'To Email(s)',
+ required: false,
+ }),
+ ccRecipients: Property.Array({
+ displayName: 'CC Email(s)',
+ required: false,
+ }),
+ bccRecipients: Property.Array({
+ displayName: 'BCC Email(s)',
+ required: false,
+ }),
+ subject: Property.ShortText({
+ displayName: 'Subject',
+ required: true,
+ }),
+ bodyFormat: Property.StaticDropdown({
+ displayName: 'Body Format',
+ required: true,
+ defaultValue: 'text',
+ options: {
+ disabled: false,
+ options: [
+ { label: 'HTML', value: 'html' },
+ { label: 'Text', value: 'text' },
+ ],
+ },
+ }),
+ body: Property.LongText({
+ displayName: 'Body',
+ required: true,
+ }),
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: `Create the draft inside a specific folder. ${outlookAtomicCommon.wellKnownFolderHint} Leave empty to use the Drafts folder.`,
+ required: false,
+ }),
+ attachments: Property.Array({
+ displayName: 'Attachments',
+ description: 'Files to attach. Total message size must stay under 3 MB.',
+ required: false,
+ properties: {
+ file: Property.File({
+ displayName: 'File',
+ required: true,
+ }),
+ fileName: Property.ShortText({
+ displayName: 'File Name',
+ required: false,
+ }),
+ },
+ }),
+ },
+ outputSchema: outlookDraftMessageActionOutputSchema,
+ async run(context) {
+ const recipients = (context.propsValue.recipients ?? []) as string[];
+ const ccRecipients = (context.propsValue.ccRecipients ?? []) as string[];
+ const bccRecipients = (context.propsValue.bccRecipients ?? []) as string[];
+ const attachments = (context.propsValue.attachments ?? []) as Array<{
+ file: ApFile;
+ fileName: string;
+ }>;
+
+ const { subject, body, bodyFormat, folderId } = context.propsValue;
+
+ const mailPayload: Message = {
+ subject,
+ body: {
+ content: body,
+ contentType: bodyFormat as BodyType,
+ },
+ toRecipients: recipients.map((mail) => ({ emailAddress: { address: mail } })),
+ ccRecipients: ccRecipients.map((mail) => ({ emailAddress: { address: mail } })),
+ bccRecipients: bccRecipients.map((mail) => ({ emailAddress: { address: mail } })),
+ attachments: attachments.map((attachment) => ({
+ '@odata.type': '#microsoft.graph.fileAttachment',
+ name: attachment.fileName || attachment.file.filename,
+ contentBytes: attachment.file.base64,
+ })),
+ };
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const path = folderId
+ ? `${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}/messages`
+ : `${prefix}/messages`;
+
+ try {
+ return await client.api(path).post(mailPayload);
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Creating the Outlook draft' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-focused-inbox-override.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-focused-inbox-override.ts
new file mode 100644
index 000000000000..501a9e7049ec
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-focused-inbox-override.ts
@@ -0,0 +1,65 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookFocusedInboxOverrideActionOutputSchema } from '../output-schemas';
+
+export const outlookCreateFocusedInboxOverrideAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_create_focused_inbox_override',
+ classification: 'WRITE',
+ displayName: 'Create Focused Inbox Override',
+ description: 'Forces mail from a sender into the Focused or Other tab.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Pins future mail from one sender address to the Focused or the Other tab. Microsoft upserts by sender address: if an override already exists for that address, its classification is updated instead of a duplicate being created, and the stored display name is left untouched unless Sender Display Name is supplied. Idempotent for that reason. Use Update Focused Inbox Override when you already hold an override ID.',
+ idempotent: true,
+ },
+ props: {
+ senderEmailAddress: Property.ShortText({
+ displayName: 'Sender Email Address',
+ description: 'SMTP address of the sender the override applies to.',
+ required: true,
+ }),
+ classifyAs: Property.StaticDropdown({
+ displayName: 'Classify As',
+ required: true,
+ defaultValue: 'focused',
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Focused', value: 'focused' },
+ { label: 'Other', value: 'other' },
+ ],
+ },
+ }),
+ senderName: Property.ShortText({
+ displayName: 'Sender Display Name',
+ description: 'Optional display name stored alongside the override.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookFocusedInboxOverrideActionOutputSchema,
+ async run(context) {
+ const { senderEmailAddress, classifyAs, senderName } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client.api(`${prefix}/inferenceClassification/overrides`).post({
+ classifyAs,
+ senderEmailAddress: {
+ ...(senderName ? { name: senderName } : {}),
+ address: senderEmailAddress,
+ },
+ });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Creating the Focused Inbox override',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-forward-draft.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-forward-draft.ts
new file mode 100644
index 000000000000..4ccc07e7d31a
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-forward-draft.ts
@@ -0,0 +1,58 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDraftMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookCreateForwardDraftAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_create_forward_draft',
+ classification: 'WRITE',
+ displayName: 'Create Forward Draft',
+ description: 'Creates an unsent forward draft for a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Creates an unsent draft that forwards an existing message to the given recipients, and returns the draft message ID so the forward can be edited before sending. Use Forward Message to send in one step, and Send Draft to dispatch this draft. Not idempotent: each call creates another draft.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ recipients: Property.Array({
+ displayName: 'To Email(s)',
+ description: 'Email addresses the draft will forward the message to.',
+ required: true,
+ }),
+ comment: Property.LongText({
+ displayName: 'Comment',
+ description: 'Optional text added above the forwarded message.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookDraftMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, comment } = context.propsValue;
+ const recipients = context.propsValue.recipients as string[];
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/createForward`)
+ .post({
+ comment: comment ?? '',
+ toRecipients: recipients.map((mail) => ({ emailAddress: { address: mail } })),
+ });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Creating the Outlook forward draft',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-mail-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-mail-folder.ts
new file mode 100644
index 000000000000..821706947d0a
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-mail-folder.ts
@@ -0,0 +1,80 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { MailFolder } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookCreateMailFolderActionOutputSchema } from '../output-schemas';
+
+export const outlookCreateMailFolderAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_create_mail_folder',
+ classification: 'WRITE',
+ displayName: 'Create Mail Folder',
+ description: 'Creates a mail folder, or returns the existing one with the same name.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Creates a mail folder at the top level or under a parent folder. It first queries the sibling folders by exact display name and returns the existing folder with created set to false when one already carries the same display name, so no duplicate folder is ever created. Idempotent: repeat calls return the same folder.',
+ idempotent: true,
+ },
+ props: {
+ displayName: Property.ShortText({
+ displayName: 'Folder Name',
+ description: 'Display name of the folder to create.',
+ required: true,
+ }),
+ parentFolderId: Property.ShortText({
+ displayName: 'Parent Folder ID',
+ description: `Create the folder under this parent. ${outlookAtomicCommon.wellKnownFolderHint} Leave empty to create it at the top level.`,
+ required: false,
+ }),
+ },
+ outputSchema: outlookCreateMailFolderActionOutputSchema,
+ async run(context) {
+ const { displayName, parentFolderId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const collectionPath = parentFolderId
+ ? `${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(parentFolderId)}/childFolders`
+ : `${prefix}/mailFolders`;
+
+ const wantedName = displayName.trim();
+
+ try {
+ const siblings: PageCollection = await client
+ .api(
+ `${collectionPath}?includeHiddenFolders=true&$select=${
+ outlookAtomicCommon.mailFolderSelect
+ }&$filter=${encodeURIComponent(`displayName eq '${wantedName.replace(/'/g, "''")}'`)}`,
+ )
+ .get();
+
+ const existing = ((siblings.value ?? []) as MailFolder[]).find(
+ (folder) => (folder.displayName ?? '').toLowerCase() === wantedName.toLowerCase(),
+ );
+
+ if (existing) {
+ return {
+ created: false,
+ folder: existing,
+ folderId: existing.id ?? null,
+ };
+ }
+
+ const folder: MailFolder = await client.api(collectionPath).post({ displayName });
+
+ return {
+ created: true,
+ folder,
+ folderId: folder?.id ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Creating the Outlook mail folder',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-reply-all-draft.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-reply-all-draft.ts
new file mode 100644
index 000000000000..134f0a192c77
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-reply-all-draft.ts
@@ -0,0 +1,49 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDraftMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookCreateReplyAllDraftAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_create_reply_all_draft',
+ classification: 'WRITE',
+ displayName: 'Create Reply-All Draft',
+ description: 'Creates an unsent reply-all draft for a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Creates an unsent draft addressed to the sender and every other recipient of an existing message, and returns the draft message ID. Use Create Reply Draft to answer only the sender, or Reply All to Message to send immediately. Send it later with Send Draft. Not idempotent: each call creates another draft.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ comment: Property.LongText({
+ displayName: 'Reply Text',
+ description: 'Optional text added above the quoted original message.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookDraftMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, comment } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/createReplyAll`)
+ .post({ comment: comment ?? '' });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Creating the Outlook reply-all draft',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-reply-draft.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-reply-draft.ts
new file mode 100644
index 000000000000..0f059a72f445
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-create-reply-draft.ts
@@ -0,0 +1,49 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDraftMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookCreateReplyDraftAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_create_reply_draft',
+ classification: 'WRITE',
+ displayName: 'Create Reply Draft',
+ description: 'Creates an unsent reply draft for a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Creates an unsent draft reply to the sender of an existing message and returns the draft message ID. Use this when the reply needs editing, extra attachments or review before it goes out; use Reply to Message to send in one step, and Send Draft to dispatch the draft afterwards. Not idempotent: each call creates another draft.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ comment: Property.LongText({
+ displayName: 'Reply Text',
+ description: 'Optional text added above the quoted original message.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookDraftMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, comment } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/createReply`)
+ .post({ comment: comment ?? '' });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Creating the Outlook reply draft',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-focused-inbox-override.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-focused-inbox-override.ts
new file mode 100644
index 000000000000..c3b3b626ef05
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-focused-inbox-override.ts
@@ -0,0 +1,54 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDeleteFocusedInboxOverrideActionOutputSchema } from '../output-schemas';
+
+export const outlookDeleteFocusedInboxOverrideAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_delete_focused_inbox_override',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Focused Inbox Override',
+ description: 'Removes a Focused Inbox sender override.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one Focused Inbox override so mail from that sender is classified by Outlook again. The override ID comes from List Focused Inbox Overrides. Not idempotent: repeating the call with the same ID fails once the override is gone.',
+ idempotent: false,
+ },
+ props: {
+ overrideId: Property.ShortText({
+ displayName: 'Override ID',
+ description: 'Override ID from List Focused Inbox Overrides.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookDeleteFocusedInboxOverrideActionOutputSchema,
+ async run(context) {
+ const { overrideId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ await client
+ .api(
+ `${prefix}/inferenceClassification/overrides/${outlookAtomicCommon.encodeGraphId(
+ overrideId,
+ )}`,
+ )
+ .delete();
+
+ return {
+ success: true,
+ message: 'Focused Inbox override deleted.',
+ overrideId,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Deleting the Focused Inbox override',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-mail-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-mail-folder.ts
new file mode 100644
index 000000000000..3cc28d59ba07
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-mail-folder.ts
@@ -0,0 +1,50 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDeleteMailFolderActionOutputSchema } from '../output-schemas';
+
+export const outlookDeleteMailFolderAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_delete_mail_folder',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Mail Folder',
+ description: 'Deletes a mail folder and everything inside it.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes a mail folder together with every message and sub-folder it contains. Move messages out with Batch Move Messages first if they must be kept. Not idempotent: repeating the call with the same ID fails once the folder is gone.',
+ idempotent: false,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: `${outlookAtomicCommon.wellKnownFolderHint} Well-known system folders cannot be deleted.`,
+ required: true,
+ }),
+ },
+ outputSchema: outlookDeleteMailFolderActionOutputSchema,
+ async run(context) {
+ const { folderId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ await client
+ .api(`${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}`)
+ .delete();
+
+ return {
+ success: true,
+ message: 'Mail folder deleted.',
+ folderId,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Deleting the Outlook mail folder',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-message-attachment.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-message-attachment.ts
new file mode 100644
index 000000000000..202751a3bcef
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-message-attachment.ts
@@ -0,0 +1,60 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDeleteMessageAttachmentActionOutputSchema } from '../output-schemas';
+
+export const outlookDeleteMessageAttachmentAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_delete_message_attachment',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Message Attachment',
+ description: 'Removes an attachment from a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Removes one attachment from a message, typically a draft, and it cannot be restored afterwards. Resolve the attachment ID with List Message Attachments first. Not idempotent: repeating the call with the same ID fails once the attachment is gone.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ attachmentId: Property.ShortText({
+ displayName: 'Attachment ID',
+ description: 'Attachment ID from List Message Attachments.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookDeleteMessageAttachmentActionOutputSchema,
+ async run(context) {
+ const { messageId, attachmentId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ await client
+ .api(
+ `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(
+ messageId,
+ )}/attachments/${outlookAtomicCommon.encodeGraphId(attachmentId)}`,
+ )
+ .delete();
+
+ return {
+ success: true,
+ message: 'Attachment deleted.',
+ messageId,
+ attachmentId,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Deleting the Outlook message attachment',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-message.ts
new file mode 100644
index 000000000000..5bcc825a62bb
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-delete-message.ts
@@ -0,0 +1,47 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDeleteMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookDeleteMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_delete_message',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Message (to Deleted Items)',
+ description: 'Moves a message to the Deleted Items folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one message by moving it to Deleted Items, where it stays recoverable. This is the safe delete to prefer; Permanently Delete Message is unrecoverable. Not idempotent: repeating the call with the same ID fails once the message has moved.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ },
+ outputSchema: outlookDeleteMessageActionOutputSchema,
+ async run(context) {
+ const { messageId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}`)
+ .delete();
+
+ return {
+ success: true,
+ message: 'Message moved to Deleted Items.',
+ messageId,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Deleting the Outlook message' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-download-message-attachment.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-download-message-attachment.ts
new file mode 100644
index 000000000000..6e90a62efcb8
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-download-message-attachment.ts
@@ -0,0 +1,94 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { ResponseType } from '@microsoft/microsoft-graph-client';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookDownloadMessageAttachmentActionOutputSchema } from '../output-schemas';
+
+export const outlookDownloadMessageAttachmentAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_download_message_attachment',
+ classification: 'READ',
+ displayName: 'Download Message Attachment',
+ description: 'Downloads one file attachment of a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Downloads the bytes of a single file attachment and stores it for downstream steps. Only file attachments can be downloaded; item attachments (an attached email or event) and reference attachments (a cloud link) are rejected with an explanatory error, so check the attachment kind with List Message Attachments first. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ attachmentId: Property.ShortText({
+ displayName: 'Attachment ID',
+ description:
+ 'Attachment ID from List Message Attachments. Attachment IDs are only valid inside their own message.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookDownloadMessageAttachmentActionOutputSchema,
+ async run(context) {
+ const { messageId, attachmentId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const attachmentPath = `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(
+ messageId,
+ )}/attachments/${outlookAtomicCommon.encodeGraphId(attachmentId)}`;
+
+ let metadata: Record;
+ try {
+ metadata = await client
+ .api(`${attachmentPath}?$select=${outlookAtomicCommon.attachmentSelect}`)
+ .get();
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Reading the Outlook attachment before download',
+ });
+ }
+
+ const attachmentType = (metadata?.['@odata.type'] ?? '') as string;
+
+ if (attachmentType !== '#microsoft.graph.fileAttachment') {
+ throw new Error(
+ `Downloading the Outlook attachment failed: this attachment is a ${
+ attachmentType || 'non-file attachment'
+ }, and only #microsoft.graph.fileAttachment items expose downloadable bytes. Use List Message Attachments to find a file attachment on this message.`,
+ );
+ }
+
+ try {
+ const bytes = await client
+ .api(`${attachmentPath}/$value`)
+ .responseType(ResponseType.ARRAYBUFFER)
+ .get();
+
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
+ const fileName = (metadata?.['name'] as string | undefined) ?? 'attachment';
+
+ const file = await context.files.write({
+ fileName,
+ data: buffer,
+ });
+
+ return {
+ messageId,
+ attachmentId,
+ fileName,
+ contentType: metadata?.['contentType'] ?? null,
+ size: buffer.length,
+ file,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Downloading the Outlook message attachment',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-forward-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-forward-message.ts
new file mode 100644
index 000000000000..42e6814155a3
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-forward-message.ts
@@ -0,0 +1,62 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookForwardMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookForwardMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_forward_message',
+ classification: 'WRITE',
+ displayName: 'Forward Message',
+ description: 'Forwards a message to new recipients.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Forwards an existing message to new recipients, keeping the original body and attachments and prepending an optional comment. Use Create Forward Draft when the forward should be edited before sending. Not idempotent: each call sends another forwarded copy.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ recipients: Property.Array({
+ displayName: 'To Email(s)',
+ description: 'Email addresses to forward the message to.',
+ required: true,
+ }),
+ comment: Property.LongText({
+ displayName: 'Comment',
+ description: 'Optional text added above the forwarded message.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookForwardMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, comment } = context.propsValue;
+ const recipients = context.propsValue.recipients as string[];
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/forward`)
+ .post({
+ comment: comment ?? '',
+ toRecipients: recipients.map((mail) => ({ emailAddress: { address: mail } })),
+ });
+
+ return {
+ success: true,
+ message: 'Message forwarded successfully.',
+ messageId,
+ recipients,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Forwarding the Outlook message' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-mail-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-mail-folder.ts
new file mode 100644
index 000000000000..80996589b087
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-mail-folder.ts
@@ -0,0 +1,46 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMailFolderActionOutputSchema } from '../output-schemas';
+
+export const outlookGetMailFolderAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_get_mail_folder',
+ classification: 'READ',
+ displayName: 'Get Mail Folder',
+ description: 'Reads one mail folder by ID or well-known name.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads a single mail folder and returns its display name, parent, child count and unread and total item counts. Accepts either an opaque folder ID or a well-known name such as inbox or drafts, which makes it the cheapest way to resolve a standard folder ID. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ defaultValue: 'inbox',
+ }),
+ },
+ outputSchema: outlookMailFolderActionOutputSchema,
+ async run(context) {
+ const { folderId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(
+ `${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}?$select=${
+ outlookAtomicCommon.mailFolderSelect
+ }`,
+ )
+ .get();
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Reading the Outlook mail folder' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-mail-tips.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-mail-tips.ts
new file mode 100644
index 000000000000..596e63b23940
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-mail-tips.ts
@@ -0,0 +1,56 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookGetMailTipsActionOutputSchema } from '../output-schemas';
+
+const mailTipsOptions =
+ 'automaticReplies, mailboxFullStatus, customMailTip, externalMemberCount, maxMessageSize, deliveryRestriction, moderationStatus, recipientScope, totalMemberCount';
+
+export const outlookGetMailTipsAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_get_mail_tips',
+ classification: 'READ',
+ displayName: 'Get Mail Tips',
+ description: 'Reads pre-send mail tips for a set of recipients.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Checks recipients before sending and reports automatic replies or out-of-office messages, full mailboxes, maximum accepted message size, delivery restrictions, moderation and whether the recipient is external. Use this ahead of Send Email when a message is important or goes outside the organisation. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ emailAddresses: Property.Array({
+ displayName: 'Recipient Email Addresses',
+ description: 'Addresses to check. Up to 100 recipients per call.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookGetMailTipsActionOutputSchema,
+ async run(context) {
+ const emailAddresses = context.propsValue.emailAddresses as string[];
+
+ if (emailAddresses.length === 0) {
+ throw new Error('Reading Outlook mail tips failed: no recipient addresses were supplied.');
+ }
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const response = await client.api(`${prefix}/getMailTips`).post({
+ EmailAddresses: emailAddresses,
+ MailTipsOptions: mailTipsOptions,
+ });
+
+ const mailTips = (response?.['value'] ?? []) as Array>;
+
+ return {
+ mailTips,
+ count: mailTips.length,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Reading Outlook mail tips' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message-attachment.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message-attachment.ts
new file mode 100644
index 000000000000..68947fe7f697
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message-attachment.ts
@@ -0,0 +1,67 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookGetMessageAttachmentActionOutputSchema } from '../output-schemas';
+
+export const outlookGetMessageAttachmentAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_get_message_attachment',
+ classification: 'READ',
+ displayName: 'Get Message Attachment',
+ description: 'Reads the metadata of one attachment.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns the metadata of a single attachment (name, content type, size, inline flag and attachment kind) without transferring the file bytes. Use Download Message Attachment when the file content itself is needed, and List Message Attachments to discover attachment IDs. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ attachmentId: Property.ShortText({
+ displayName: 'Attachment ID',
+ description:
+ 'Attachment ID from List Message Attachments. Attachment IDs are only valid inside their own message.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookGetMessageAttachmentActionOutputSchema,
+ async run(context) {
+ const { messageId, attachmentId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const attachment = await client
+ .api(
+ `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(
+ messageId,
+ )}/attachments/${outlookAtomicCommon.encodeGraphId(attachmentId)}?$select=${
+ outlookAtomicCommon.attachmentSelect
+ }`,
+ )
+ .get();
+
+ return {
+ messageId,
+ id: attachment?.['id'] ?? null,
+ name: attachment?.['name'] ?? null,
+ contentType: attachment?.['contentType'] ?? null,
+ size: attachment?.['size'] ?? null,
+ isInline: attachment?.['isInline'] ?? null,
+ lastModifiedDateTime: attachment?.['lastModifiedDateTime'] ?? null,
+ attachmentType: attachment?.['@odata.type'] ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Reading the Outlook message attachment',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message-mime.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message-mime.ts
new file mode 100644
index 000000000000..69eae27a0ef0
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message-mime.ts
@@ -0,0 +1,62 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { ResponseType } from '@microsoft/microsoft-graph-client';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookGetMessageMimeActionOutputSchema } from '../output-schemas';
+
+export const outlookGetMessageMimeAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_get_message_mime',
+ classification: 'READ',
+ displayName: 'Get Message MIME Content',
+ description: 'Downloads the raw MIME (RFC-822) content of a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Stores the raw RFC-822 MIME source of one Outlook message, including full internet headers, as a byte-exact .eml file and returns that file plus its size. The content is not returned inline, because MIME can carry 8-bit and non-UTF-8 bytes that no text field round-trips; read the file when you need the headers. Use this for archival or forensic inspection; use Get Message when the parsed fields are enough. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ },
+ outputSchema: outlookGetMessageMimeActionOutputSchema,
+ async run(context) {
+ const { messageId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const bytes = await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/$value`)
+ .responseType(ResponseType.ARRAYBUFFER)
+ .get();
+
+ const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes);
+ const fileName = 'message.eml';
+
+ const file = await context.files.write({
+ fileName,
+ data: buffer,
+ });
+
+ return {
+ messageId,
+ fileName,
+ contentType: 'message/rfc822',
+ size: buffer.length,
+ file,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Downloading the Outlook message MIME content',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message.ts
new file mode 100644
index 000000000000..67735f62e2b1
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-message.ts
@@ -0,0 +1,42 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookGetMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_get_message',
+ classification: 'READ',
+ displayName: 'Get Message',
+ description: 'Retrieves a single message by its ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads one Outlook message by ID and returns its headers, plain-text body and metadata. Use this after List Messages or Search Messages to inspect a specific email; use Get Message MIME Content when you need the raw RFC-822 source. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ },
+ outputSchema: outlookMessageActionOutputSchema,
+ async run(context) {
+ const { messageId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}`)
+ .headers(outlookAtomicCommon.textBodyHeaders)
+ .get();
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Reading the Outlook message' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-my-profile.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-my-profile.ts
new file mode 100644
index 000000000000..124ee2d161d8
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-my-profile.ts
@@ -0,0 +1,49 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookGetMyProfileActionOutputSchema } from '../output-schemas';
+
+export const outlookGetMyProfileAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_get_my_profile',
+ classification: 'READ',
+ displayName: 'Get My Profile',
+ description: 'Reads the identity of the connected account.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns the identity behind the connection: display name, primary email address and user principal name. Use this to learn your own address before composing mail, filtering a thread or deciding whether a sender is you. On app-only connections it reports the configured mailbox without calling Microsoft Graph, because reading another user profile would need a directory permission this piece does not request. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {},
+ outputSchema: outlookGetMyProfileActionOutputSchema,
+ async run(context) {
+ if (outlookAtomicCommon.isAppOnlyConnection(context.auth)) {
+ const mailbox = outlookAtomicCommon.getConfiguredMailbox(context.auth);
+ return {
+ id: null,
+ displayName: null,
+ mail: mailbox,
+ userPrincipalName: mailbox,
+ source: 'connection-mailbox',
+ };
+ }
+
+ const client = outlookCommon.createClient(context.auth);
+
+ try {
+ const profile = await client.api('/me?$select=id,displayName,mail,userPrincipalName').get();
+
+ return {
+ id: profile?.['id'] ?? null,
+ displayName: profile?.['displayName'] ?? null,
+ mail: profile?.['mail'] ?? null,
+ userPrincipalName: profile?.['userPrincipalName'] ?? null,
+ source: 'graph',
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Reading the Outlook profile' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-supported-time-zones.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-supported-time-zones.ts
new file mode 100644
index 000000000000..10b7d50f939f
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-get-supported-time-zones.ts
@@ -0,0 +1,50 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookGetSupportedTimeZonesActionOutputSchema } from '../output-schemas';
+
+export const outlookGetSupportedTimeZonesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_get_supported_time_zones',
+ classification: 'READ',
+ displayName: 'Get Supported Time Zones',
+ description: 'Lists the time zones Outlook accepts for this account.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists the time zone names Outlook accepts, so a time zone string can be validated before it is used elsewhere. Works on delegated connections only: Microsoft exposes no application permission for it, so app-only connections are rejected with an explanatory error. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {},
+ outputSchema: outlookGetSupportedTimeZonesActionOutputSchema,
+ async run(context) {
+ if (outlookAtomicCommon.isAppOnlyConnection(context.auth)) {
+ throw new Error(
+ 'Listing the supported time zones is available on delegated Outlook connections only. Microsoft Graph exposes no application permission for this endpoint, so an app-only connection with a configured mailbox cannot call it.',
+ );
+ }
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const response: PageCollection = await client
+ .api(`${prefix}/outlook/supportedTimeZones`)
+ .get();
+
+ const timeZones = (response.value ?? []) as Array>;
+
+ return {
+ timeZones,
+ count: timeZones.length,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Listing the supported Outlook time zones',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-child-mail-folders.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-child-mail-folders.ts
new file mode 100644
index 000000000000..f60aa8dd17ab
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-child-mail-folders.ts
@@ -0,0 +1,71 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { MailFolder } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookListChildMailFoldersActionOutputSchema } from '../output-schemas';
+
+export const outlookListChildMailFoldersAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_list_child_mail_folders',
+ classification: 'SEARCH',
+ displayName: 'List Child Mail Folders',
+ description: 'Lists the sub-folders of one mail folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists the direct sub-folders of a given mail folder, including hidden ones, so nested hierarchies can be walked one level at a time. Use List Mail Folders for the top level; a child folder ID works anywhere a folder ID is accepted. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Parent Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ defaultValue: 'inbox',
+ }),
+ maxResults: Property.Number({
+ displayName: 'Max Results',
+ description: 'Maximum number of folders to return (1-100).',
+ required: false,
+ defaultValue: 50,
+ }),
+ },
+ outputSchema: outlookListChildMailFoldersActionOutputSchema,
+ async run(context) {
+ const { folderId, maxResults } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const top = Math.min(Math.max(maxResults ?? 50, 1), 100);
+
+ try {
+ const response: PageCollection = await client
+ .api(
+ `${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(
+ folderId,
+ )}/childFolders?includeHiddenFolders=true&$select=${
+ outlookAtomicCommon.mailFolderSelect
+ }&$top=${top}`,
+ )
+ .get();
+
+ const folders = (response.value ?? []) as MailFolder[];
+ const nextLink = response['@odata.nextLink'] as string | undefined;
+
+ return {
+ parentFolderId: folderId,
+ folders,
+ count: folders.length,
+ hasMore: !!nextLink,
+ nextLink: nextLink ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Listing the Outlook child mail folders',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-focused-inbox-overrides.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-focused-inbox-overrides.ts
new file mode 100644
index 000000000000..165d7ef2f962
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-focused-inbox-overrides.ts
@@ -0,0 +1,44 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookListFocusedInboxOverridesActionOutputSchema } from '../output-schemas';
+
+export const outlookListFocusedInboxOverridesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_list_focused_inbox_overrides',
+ classification: 'SEARCH',
+ displayName: 'List Focused Inbox Overrides',
+ description: 'Lists the Focused Inbox sender overrides of the mailbox.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists the Focused Inbox overrides that force mail from specific senders into the Focused or Other tab, with the override ID for each sender. Run this first: Update Focused Inbox Override and Delete Focused Inbox Override both need an ID that only this action returns. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {},
+ outputSchema: outlookListFocusedInboxOverridesActionOutputSchema,
+ async run(context) {
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const response: PageCollection = await client
+ .api(`${prefix}/inferenceClassification/overrides`)
+ .get();
+
+ const overrides = (response.value ?? []) as Array>;
+
+ return {
+ overrides,
+ count: overrides.length,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Listing the Focused Inbox overrides',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-mail-folders-delta.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-mail-folders-delta.ts
new file mode 100644
index 000000000000..fdf2ff557798
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-mail-folders-delta.ts
@@ -0,0 +1,68 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { MailFolder } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookListMailFoldersDeltaActionOutputSchema } from '../output-schemas';
+
+export const outlookListMailFoldersDeltaAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_list_mail_folders_delta',
+ classification: 'SEARCH',
+ displayName: 'List Folder Changes (Delta)',
+ description: 'Tracks created, renamed and deleted mail folders.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns the mail folders created, renamed or removed since a previous delta link, plus a fresh delta link for the next call. Use this to keep a folder tree in sync instead of re-listing it; it pairs with List Message Changes (Delta) for messages. The delta link must be one this action returned on the same connection: any other URL is rejected instead of being followed. Each change carries a removed flag: when it is true the folder was deleted and only its id and removedReason are meaningful. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ deltaLink: Property.ShortText({
+ displayName: 'Delta Link',
+ description: `The deltaLink returned by a previous run. Leave empty to start a new sync from the current folder tree. ${outlookAtomicCommon.deltaLinkHint}`,
+ required: false,
+ }),
+ },
+ outputSchema: outlookListMailFoldersDeltaActionOutputSchema,
+ async run(context) {
+ const { deltaLink } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const operation = 'Listing the Outlook mail folder changes';
+ const expectedPath = `${prefix}/mailFolders/delta`;
+ const url = deltaLink
+ ? outlookAtomicCommon.resolveDeltaUrl({
+ auth: context.auth,
+ deltaLink,
+ expectedPath,
+ operation,
+ })
+ : expectedPath;
+
+ try {
+ const response: PageCollection = await client
+ .api(url)
+ .headers({ Prefer: 'odata.maxpagesize=50' })
+ .get();
+
+ const changes = ((response.value ?? []) as MailFolder[]).map((change) =>
+ outlookAtomicCommon.withDeltaRemoval(change),
+ );
+ const nextLink = response['@odata.nextLink'] as string | undefined;
+ const nextDeltaLink = response['@odata.deltaLink'] as string | undefined;
+
+ return {
+ changes,
+ count: changes.length,
+ hasMore: !!nextLink,
+ nextLink: nextLink ?? null,
+ deltaLink: nextDeltaLink ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-mail-folders.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-mail-folders.ts
new file mode 100644
index 000000000000..e1a347abe4dd
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-mail-folders.ts
@@ -0,0 +1,57 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { MailFolder } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookListMailFoldersActionOutputSchema } from '../output-schemas';
+
+export const outlookListMailFoldersAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_list_mail_folders',
+ classification: 'SEARCH',
+ displayName: 'List Mail Folders',
+ description: 'Lists the top-level mail folders of the mailbox.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists the top-level mail folders with their IDs, display names and item counts, including hidden folders. Use this to resolve a folder ID before moving, copying or listing messages; use List Child Mail Folders to walk deeper into the tree. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ maxResults: Property.Number({
+ displayName: 'Max Results',
+ description: 'Maximum number of folders to return (1-100).',
+ required: false,
+ defaultValue: 50,
+ }),
+ },
+ outputSchema: outlookListMailFoldersActionOutputSchema,
+ async run(context) {
+ const { maxResults } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const top = Math.min(Math.max(maxResults ?? 50, 1), 100);
+
+ try {
+ const response: PageCollection = await client
+ .api(
+ `${prefix}/mailFolders?includeHiddenFolders=true&$select=${outlookAtomicCommon.mailFolderSelect}&$top=${top}`,
+ )
+ .get();
+
+ const folders = (response.value ?? []) as MailFolder[];
+ const nextLink = response['@odata.nextLink'] as string | undefined;
+
+ return {
+ folders,
+ count: folders.length,
+ hasMore: !!nextLink,
+ nextLink: nextLink ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Listing the Outlook mail folders' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-message-attachments.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-message-attachments.ts
new file mode 100644
index 000000000000..de3b4deaf4c1
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-message-attachments.ts
@@ -0,0 +1,65 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookListMessageAttachmentsActionOutputSchema } from '../output-schemas';
+
+export const outlookListMessageAttachmentsAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_list_message_attachments',
+ classification: 'SEARCH',
+ displayName: 'List Message Attachments',
+ description: 'Lists the attachments of a message without downloading them.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists every attachment on one message with its ID, name, content type, size and kind, without transferring any file bytes. Use this first to pick an attachment ID for Download Message Attachment or Get Message Attachment. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ },
+ outputSchema: outlookListMessageAttachmentsActionOutputSchema,
+ async run(context) {
+ const { messageId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const response: PageCollection = await client
+ .api(
+ `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(
+ messageId,
+ )}/attachments?$select=${outlookAtomicCommon.attachmentSelect}`,
+ )
+ .get();
+
+ const attachments = (response.value ?? []) as Array>;
+
+ return {
+ messageId,
+ attachments: attachments.map((attachment) => ({
+ id: attachment['id'] ?? null,
+ name: attachment['name'] ?? null,
+ contentType: attachment['contentType'] ?? null,
+ size: attachment['size'] ?? null,
+ isInline: attachment['isInline'] ?? null,
+ lastModifiedDateTime: attachment['lastModifiedDateTime'] ?? null,
+ attachmentType: attachment['@odata.type'] ?? null,
+ })),
+ count: attachments.length,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Listing the Outlook message attachments',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-messages-delta.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-messages-delta.ts
new file mode 100644
index 000000000000..92984a1f99e9
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-messages-delta.ts
@@ -0,0 +1,74 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookListMessagesDeltaActionOutputSchema } from '../output-schemas';
+
+export const outlookListMessagesDeltaAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_list_messages_delta',
+ classification: 'SEARCH',
+ displayName: 'List Message Changes (Delta)',
+ description: 'Tracks created, updated and deleted messages in a folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns the messages added, changed or removed in one mail folder since a previous delta link, plus a fresh delta link for the next call. Use this for incremental sync instead of re-listing a folder; a first call without a delta link returns the current contents. The delta link must be one this action returned on the same connection: any other URL is rejected instead of being followed. Each change carries a removed flag: when it is true the item was deleted and only its id and removedReason are meaningful. Delta payloads omit message bodies and attachments, so follow up with Get Message for full content. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: `Folder to track. ${outlookAtomicCommon.wellKnownFolderHint}`,
+ required: true,
+ defaultValue: 'inbox',
+ }),
+ deltaLink: Property.ShortText({
+ displayName: 'Delta Link',
+ description: `The deltaLink returned by a previous run. Leave empty to start a new sync from the current folder state. ${outlookAtomicCommon.deltaLinkHint}`,
+ required: false,
+ }),
+ },
+ outputSchema: outlookListMessagesDeltaActionOutputSchema,
+ async run(context) {
+ const { folderId, deltaLink } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ const operation = 'Listing Outlook message changes';
+ const url = deltaLink
+ ? outlookAtomicCommon.resolveDeltaUrl({
+ auth: context.auth,
+ deltaLink,
+ expectedPath: `${prefix}/mailFolders/*/messages/delta`,
+ operation,
+ })
+ : `${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}/messages/delta?$select=${outlookAtomicCommon.messageSelect}`;
+
+ try {
+ const response: PageCollection = await client
+ .api(url)
+ .headers({ Prefer: 'odata.maxpagesize=50' })
+ .get();
+
+ const changes = ((response.value ?? []) as Message[]).map((change) =>
+ outlookAtomicCommon.withDeltaRemoval(change),
+ );
+ const nextLink = response['@odata.nextLink'] as string | undefined;
+ const nextDeltaLink = response['@odata.deltaLink'] as string | undefined;
+
+ return {
+ changes,
+ count: changes.length,
+ hasMore: !!nextLink,
+ nextLink: nextLink ?? null,
+ deltaLink: nextDeltaLink ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-messages.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-messages.ts
new file mode 100644
index 000000000000..a4b1a7d4f2a8
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-list-messages.ts
@@ -0,0 +1,87 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookListMessagesActionOutputSchema } from '../output-schemas';
+
+export const outlookListMessagesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_list_messages',
+ classification: 'SEARCH',
+ displayName: 'List Messages',
+ description: 'Lists messages from the mailbox or a single mail folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Enumerates Outlook messages, optionally scoped to one mail folder and narrowed by an OData $filter expression, and returns their IDs and headers. Use this to discover message IDs for other actions; use Search Messages instead when you only have free-text keywords. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: `Limit the listing to one folder. ${outlookAtomicCommon.wellKnownFolderHint} Leave empty to list the whole mailbox.`,
+ required: false,
+ }),
+ filter: Property.ShortText({
+ displayName: 'Filter',
+ description:
+ 'Optional OData $filter expression, for example: isRead eq false, or receivedDateTime ge 2024-01-01T00:00:00Z.',
+ required: false,
+ }),
+ maxResults: Property.Number({
+ displayName: 'Max Results',
+ description: 'Maximum number of messages to return (1-100).',
+ required: false,
+ defaultValue: 25,
+ }),
+ skip: Property.Number({
+ displayName: 'Skip',
+ description: 'Number of messages to skip before returning results, for paging.',
+ required: false,
+ }),
+ },
+ outputSchema: outlookListMessagesActionOutputSchema,
+ async run(context) {
+ const { folderId, filter, maxResults, skip } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const basePath = folderId
+ ? `${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}/messages`
+ : `${prefix}/messages`;
+
+ const top = Math.min(Math.max(maxResults ?? 25, 1), 100);
+ const queryParams = [`$select=${outlookAtomicCommon.messageSelect}`, `$top=${top}`];
+
+ if (filter) {
+ queryParams.push(`$filter=${encodeURIComponent(filter)}`);
+ } else {
+ queryParams.push('$orderby=receivedDateTime%20desc');
+ }
+
+ if (skip !== undefined && skip > 0) {
+ queryParams.push(`$skip=${Math.floor(skip)}`);
+ }
+
+ try {
+ const response: PageCollection = await client
+ .api(`${basePath}?${queryParams.join('&')}`)
+ .headers(outlookAtomicCommon.textBodyHeaders)
+ .get();
+
+ const messages = (response.value ?? []) as Message[];
+ const nextLink = response['@odata.nextLink'] as string | undefined;
+
+ return {
+ messages,
+ count: messages.length,
+ hasMore: !!nextLink,
+ nextLink: nextLink ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Listing Outlook messages' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-move-mail-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-move-mail-folder.ts
new file mode 100644
index 000000000000..16a14c474df3
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-move-mail-folder.ts
@@ -0,0 +1,58 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { MailFolder } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMoveMailFolderActionOutputSchema } from '../output-schemas';
+
+export const outlookMoveMailFolderAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_move_mail_folder',
+ classification: 'WRITE',
+ displayName: 'Move Mail Folder',
+ description: 'Moves a mail folder under another folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reparents a mail folder, moving it with its messages and sub-folders under a different destination folder. Use Copy Mail Folder to duplicate instead of relocating, and Rename Mail Folder to change only its name. The move issues the folder a new ID: carry newFolderId forward into later steps, not the folderId that was passed in. Not idempotent: once moved, the same call repeated against the old parent no longer applies.',
+ idempotent: false,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ }),
+ destinationFolderId: Property.ShortText({
+ displayName: 'Destination Folder ID',
+ description: `New parent folder. ${outlookAtomicCommon.wellKnownFolderHint}`,
+ required: true,
+ }),
+ },
+ outputSchema: outlookMoveMailFolderActionOutputSchema,
+ async run(context) {
+ const { folderId, destinationFolderId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const moved: MailFolder = await client
+ .api(`${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}/move`)
+ .post({ destinationId: destinationFolderId });
+
+ return {
+ success: true,
+ folderId,
+ newFolderId: moved?.id ?? null,
+ destinationFolderId,
+ folder: moved,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Moving the Outlook mail folder',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-move-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-move-message.ts
new file mode 100644
index 000000000000..0c14a575a3ae
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-move-message.ts
@@ -0,0 +1,55 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMoveMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookMoveMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_move_message',
+ classification: 'WRITE',
+ displayName: 'Move Message',
+ description: 'Moves a message to another mail folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Moves one message into another mail folder and returns the moved copy. The move mints a NEW message ID: the original ID stops resolving, so use the returned newMessageId for any follow-up step. Use Copy Message to leave the original in place, or Batch Move Messages for many messages. Not idempotent: a retry with the old ID fails.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ destinationFolderId: Property.ShortText({
+ displayName: 'Destination Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ }),
+ },
+ outputSchema: outlookMoveMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, destinationFolderId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ const moved: Message = await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/move`)
+ .post({ destinationId: destinationFolderId });
+
+ return {
+ success: true,
+ previousMessageId: messageId,
+ newMessageId: moved?.id ?? null,
+ destinationFolderId,
+ message: moved,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Moving the Outlook message' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-permanently-delete-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-permanently-delete-message.ts
new file mode 100644
index 000000000000..9fca74de23a3
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-permanently-delete-message.ts
@@ -0,0 +1,103 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+
+export const outlookPermanentlyDeleteMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_permanently_delete_message',
+ classification: 'DESTRUCTIVE',
+ displayName: 'Permanently Delete Message',
+ description: 'Permanently deletes a message so it cannot be recovered.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Permanently removes one message from the mailbox with no way to recover it, bypassing Deleted Items. Prefer Delete Message (to Deleted Items) in every case where recovery might be wanted; only use this when the caller explicitly asked for unrecoverable deletion. Unavailable on US Government DoD and 21Vianet clouds. Not idempotent: a retry with the same ID fails.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ confirm: Property.Checkbox({
+ displayName: 'Confirm Permanent Deletion',
+ description: 'Must be enabled. The message cannot be recovered afterwards.',
+ required: true,
+ defaultValue: false,
+ }),
+ },
+ async run(context) {
+ const { messageId, confirm } = context.propsValue;
+
+ if (!confirm) {
+ throw new Error(
+ 'Permanently deleting the Outlook message was refused: enable Confirm Permanent Deletion to proceed, or use Delete Message (to Deleted Items) instead.',
+ );
+ }
+
+ if (outlookAtomicCommon.isGovernmentCloud(context.auth)) {
+ throw new Error(
+ 'Permanently deleting the Outlook message is not available on the US Government cloud. Use Delete Message (to Deleted Items) instead.',
+ );
+ }
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const encodedMessageId = outlookAtomicCommon.encodeGraphId(messageId);
+
+ try {
+ await client.api(`${prefix}/messages/${encodedMessageId}/permanentDelete`).post({});
+
+ return {
+ success: true,
+ message: 'Message permanently deleted.',
+ messageId,
+ };
+ } catch (error) {
+ const retryableAsUserPath =
+ prefix === '/me' && outlookAtomicCommon.isUnsupportedMePathError(error);
+
+ if (!retryableAsUserPath) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Permanently deleting the Outlook message',
+ });
+ }
+
+ try {
+ const profile = await client.api('/me?$select=id').get();
+ const userId = profile?.['id'] as string | undefined;
+
+ if (!userId) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Permanently deleting the Outlook message',
+ });
+ }
+
+ await client
+ .api(
+ `/users/${outlookAtomicCommon.encodeGraphId(
+ userId,
+ )}/messages/${encodedMessageId}/permanentDelete`,
+ )
+ .post({});
+
+ return {
+ success: true,
+ message: 'Message permanently deleted.',
+ messageId,
+ };
+ } catch (fallbackError) {
+ throw outlookAtomicCommon.graphError({
+ error: fallbackError,
+ operation: `Permanently deleting the Outlook message (retried against /users/{id} after the /me path was rejected with: ${outlookAtomicCommon.graphErrorDetail(
+ error,
+ )})`,
+ });
+ }
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-remove-message-categories.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-remove-message-categories.ts
new file mode 100644
index 000000000000..a6b14e750f45
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-remove-message-categories.ts
@@ -0,0 +1,56 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookRemoveMessageCategoriesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_remove_message_categories',
+ classification: 'WRITE',
+ displayName: 'Remove Categories from Message',
+ description: 'Removes categories (labels) from a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Removes one or more Outlook categories from a message while leaving its other categories intact. Use Add Categories to Message for the inverse, and Update Message when other fields change too. Idempotent: removing a category the message does not have changes nothing.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ categories: Property.Array({
+ displayName: 'Categories to Remove',
+ description: 'Category names to remove from the message.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookMessageActionOutputSchema,
+ async run(context) {
+ const { messageId } = context.propsValue;
+ const categories = context.propsValue.categories as string[];
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const path = `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}`;
+
+ try {
+ const current = await client.api(`${path}?$select=id,categories`).get();
+ const existing = (current?.['categories'] ?? []) as string[];
+ const remaining = existing.filter((category) => !categories.includes(category));
+
+ return await client
+ .api(path)
+ .headers(outlookAtomicCommon.textBodyHeaders)
+ .patch({ categories: remaining });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Removing categories from the Outlook message',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-reply-all-to-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-reply-all-to-message.ts
new file mode 100644
index 000000000000..56d92ffb0ba2
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-reply-all-to-message.ts
@@ -0,0 +1,55 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookReplyAllToMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookReplyAllToMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_reply_all_to_message',
+ classification: 'WRITE',
+ displayName: 'Reply All to Message',
+ description: 'Replies to the sender and all recipients of a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Sends a reply to the sender and every other recipient of an existing message. Use Reply to Message to answer the sender only, or Create Reply-All Draft when the reply must be reviewed first. Not idempotent: each call sends another reply to everyone on the thread.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ comment: Property.LongText({
+ displayName: 'Reply Text',
+ description: 'Text added above the quoted original message.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookReplyAllToMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, comment } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/replyAll`)
+ .post({ comment });
+
+ return {
+ success: true,
+ message: 'Reply-all sent successfully.',
+ messageId,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Replying to all recipients of the Outlook message',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-reply-to-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-reply-to-message.ts
new file mode 100644
index 000000000000..de0e6a36c6b9
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-reply-to-message.ts
@@ -0,0 +1,65 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookReplyToMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookReplyToMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_reply_to_message',
+ classification: 'WRITE',
+ displayName: 'Reply to Message',
+ description: 'Replies to the sender of a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Sends a reply to the sender of an existing message, quoting the original thread. Use Reply All to Message to include every original recipient, or Create Reply Draft when the reply must be reviewed before sending. Not idempotent: each call sends another reply.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ comment: Property.LongText({
+ displayName: 'Reply Text',
+ description: 'Text added above the quoted original message.',
+ required: true,
+ }),
+ ccRecipients: Property.Array({
+ displayName: 'Additional CC Email(s)',
+ required: false,
+ }),
+ },
+ outputSchema: outlookReplyToMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, comment } = context.propsValue;
+ const ccRecipients = (context.propsValue.ccRecipients ?? []) as string[];
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ const payload: { comment: string; message?: Message } = { comment };
+ if (ccRecipients.length > 0) {
+ payload.message = {
+ ccRecipients: ccRecipients.map((mail) => ({ emailAddress: { address: mail } })),
+ };
+ }
+
+ try {
+ await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/reply`)
+ .post(payload);
+
+ return {
+ success: true,
+ message: 'Reply sent successfully.',
+ messageId,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Replying to the Outlook message' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-search-messages.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-search-messages.ts
new file mode 100644
index 000000000000..9709fcee5fd2
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-search-messages.ts
@@ -0,0 +1,82 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { PageCollection } from '@microsoft/microsoft-graph-client';
+import { Message } from '@microsoft/microsoft-graph-types';
+import dayjs from 'dayjs';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookSearchMessagesActionOutputSchema } from '../output-schemas';
+
+export const outlookSearchMessagesAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_search_messages',
+ classification: 'SEARCH',
+ displayName: 'Search Messages',
+ description: 'Searches messages with a full-text query.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Runs a full-text Outlook search (KQL terms such as from:jane@contoso.com, subject:invoice or hasAttachments:true), optionally inside one folder, and returns matching messages newest first. Pick this when you have keywords; use List Messages when you need an exact OData filter or paging. Search cannot be combined with filters or server-side sorting, and is unreliable on personal Microsoft accounts. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ searchQuery: Property.ShortText({
+ displayName: 'Search Query',
+ description:
+ 'Keywords or KQL terms to match, for example: from:john@example.com, subject:urgent, hasAttachments:true.',
+ required: true,
+ }),
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: `Search inside one folder only. ${outlookAtomicCommon.wellKnownFolderHint} Leave empty to search the whole mailbox.`,
+ required: false,
+ }),
+ maxResults: Property.Number({
+ displayName: 'Max Results',
+ description: 'Maximum number of results to return (1-1000).',
+ required: false,
+ defaultValue: 25,
+ }),
+ },
+ outputSchema: outlookSearchMessagesActionOutputSchema,
+ async run(context) {
+ const { searchQuery, folderId, maxResults } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const basePath = folderId
+ ? `${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}/messages`
+ : `${prefix}/messages`;
+
+ const top = Math.min(Math.max(maxResults ?? 25, 1), 1000);
+ const url = `${basePath}?$search=${encodeURIComponent(`"${searchQuery}"`)}&$top=${top}`;
+
+ try {
+ const response: PageCollection = await client
+ .api(url)
+ .headers({
+ ConsistencyLevel: 'eventual',
+ ...outlookAtomicCommon.textBodyHeaders,
+ })
+ .get();
+
+ const messages = (response.value ?? []) as Message[];
+ messages.sort(
+ (first, second) =>
+ dayjs(second.receivedDateTime).valueOf() - dayjs(first.receivedDateTime).valueOf(),
+ );
+
+ const nextLink = response['@odata.nextLink'] as string | undefined;
+
+ return {
+ found: messages.length > 0,
+ messages,
+ count: messages.length,
+ hasMore: !!nextLink,
+ nextLink: nextLink ?? null,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Searching Outlook messages' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-send-draft.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-send-draft.ts
new file mode 100644
index 000000000000..92183e242559
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-send-draft.ts
@@ -0,0 +1,48 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookSendDraftActionOutputSchema } from '../output-schemas';
+
+export const outlookSendDraftAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_send_draft',
+ classification: 'WRITE',
+ displayName: 'Send Draft',
+ description: 'Sends an existing draft message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Sends a draft that already exists in the mailbox, identified by its message ID. Use this after Create Draft, Create Reply Draft, Create Reply-All Draft or Create Forward Draft. Not idempotent: the draft stops existing once sent, so a repeat call with the same ID fails.',
+ idempotent: false,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Draft Message ID',
+ description:
+ 'ID of the draft to send. Obtain it from Create Draft or one of the Create ... Draft actions.',
+ required: true,
+ }),
+ },
+ outputSchema: outlookSendDraftActionOutputSchema,
+ async run(context) {
+ const { messageId } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}/send`)
+ .post({});
+
+ return {
+ success: true,
+ message: 'Draft sent successfully.',
+ messageId,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Sending the Outlook draft' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-send-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-send-email.ts
new file mode 100644
index 000000000000..916b919e3aaa
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-send-email.ts
@@ -0,0 +1,116 @@
+import { ApFile, createAction, Property } from '@activepieces/pieces-framework';
+import { BodyType, Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookSendEmailActionOutputSchema } from '../output-schemas';
+
+export const outlookSendEmailAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_send_email',
+ classification: 'WRITE',
+ displayName: 'Send Email',
+ description: 'Composes and sends a new email.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Composes and immediately sends a brand new email from the connected Outlook mailbox, with optional CC, BCC and attachments, and saves a copy in Sent Items. Use Reply to Message or Forward Message instead when responding to existing mail, and Create Draft when the message should be staged rather than sent. Not idempotent: every call dispatches another email.',
+ idempotent: false,
+ },
+ props: {
+ recipients: Property.Array({
+ displayName: 'To Email(s)',
+ description: 'Email addresses of the primary recipients.',
+ required: true,
+ }),
+ ccRecipients: Property.Array({
+ displayName: 'CC Email(s)',
+ required: false,
+ }),
+ bccRecipients: Property.Array({
+ displayName: 'BCC Email(s)',
+ required: false,
+ }),
+ subject: Property.ShortText({
+ displayName: 'Subject',
+ required: true,
+ }),
+ bodyFormat: Property.StaticDropdown({
+ displayName: 'Body Format',
+ required: true,
+ defaultValue: 'text',
+ options: {
+ disabled: false,
+ options: [
+ { label: 'HTML', value: 'html' },
+ { label: 'Text', value: 'text' },
+ ],
+ },
+ }),
+ body: Property.LongText({
+ displayName: 'Body',
+ required: true,
+ }),
+ attachments: Property.Array({
+ displayName: 'Attachments',
+ description: 'Files to attach. Total message size must stay under 3 MB.',
+ required: false,
+ properties: {
+ file: Property.File({
+ displayName: 'File',
+ required: true,
+ }),
+ fileName: Property.ShortText({
+ displayName: 'File Name',
+ required: false,
+ }),
+ },
+ }),
+ },
+ outputSchema: outlookSendEmailActionOutputSchema,
+ async run(context) {
+ const recipients = context.propsValue.recipients as string[];
+ const ccRecipients = (context.propsValue.ccRecipients ?? []) as string[];
+ const bccRecipients = (context.propsValue.bccRecipients ?? []) as string[];
+ const attachments = (context.propsValue.attachments ?? []) as Array<{
+ file: ApFile;
+ fileName: string;
+ }>;
+
+ const { subject, body, bodyFormat } = context.propsValue;
+
+ const mailPayload: Message = {
+ subject,
+ body: {
+ content: body,
+ contentType: bodyFormat as BodyType,
+ },
+ toRecipients: recipients.map((mail) => ({ emailAddress: { address: mail } })),
+ ccRecipients: ccRecipients.map((mail) => ({ emailAddress: { address: mail } })),
+ bccRecipients: bccRecipients.map((mail) => ({ emailAddress: { address: mail } })),
+ attachments: attachments.map((attachment) => ({
+ '@odata.type': '#microsoft.graph.fileAttachment',
+ name: attachment.fileName || attachment.file.filename,
+ contentBytes: attachment.file.base64,
+ })),
+ };
+
+ const client = outlookCommon.createClient(context.auth);
+
+ try {
+ await client.api(`${outlookCommon.mailboxPrefix(context.auth)}/sendMail`).post({
+ message: mailPayload,
+ saveToSentItems: 'true',
+ });
+
+ return {
+ success: true,
+ message: 'Email sent successfully.',
+ subject,
+ recipients,
+ };
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Sending the Outlook email' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-set-message-read-status.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-set-message-read-status.ts
new file mode 100644
index 000000000000..7f2f28275270
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-set-message-read-status.ts
@@ -0,0 +1,57 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookSetMessageReadStatusAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_set_message_read_status',
+ classification: 'WRITE',
+ displayName: 'Mark Message Read / Unread',
+ description: 'Marks a message as read or unread.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Sets only the read state of one message, leaving every other property untouched. Use this for the common mark-as-read step; use Update Message when other fields change too, or Batch Update Messages for many messages at once. Idempotent: re-applying the same state changes nothing.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ isRead: Property.StaticDropdown({
+ displayName: 'Read State',
+ required: true,
+ defaultValue: 'true',
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Mark as read', value: 'true' },
+ { label: 'Mark as unread', value: 'false' },
+ ],
+ },
+ }),
+ },
+ outputSchema: outlookMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, isRead } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(`${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}`)
+ .headers(outlookAtomicCommon.textBodyHeaders)
+ .patch({ isRead: isRead === 'true' });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Updating the read state of the Outlook message',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-focused-inbox-override.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-focused-inbox-override.ts
new file mode 100644
index 000000000000..2b3002cf820f
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-focused-inbox-override.ts
@@ -0,0 +1,59 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookFocusedInboxOverrideActionOutputSchema } from '../output-schemas';
+
+export const outlookUpdateFocusedInboxOverrideAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_update_focused_inbox_override',
+ classification: 'WRITE',
+ displayName: 'Update Focused Inbox Override',
+ description: 'Changes the classification of a Focused Inbox override.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Switches an existing Focused Inbox override between the Focused and Other tabs. The classification is the only writable property, and the override ID comes from List Focused Inbox Overrides. Idempotent: applying the same classification again changes nothing.',
+ idempotent: true,
+ },
+ props: {
+ overrideId: Property.ShortText({
+ displayName: 'Override ID',
+ description: 'Override ID from List Focused Inbox Overrides.',
+ required: true,
+ }),
+ classifyAs: Property.StaticDropdown({
+ displayName: 'Classify As',
+ required: true,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Focused', value: 'focused' },
+ { label: 'Other', value: 'other' },
+ ],
+ },
+ }),
+ },
+ outputSchema: outlookFocusedInboxOverrideActionOutputSchema,
+ async run(context) {
+ const { overrideId, classifyAs } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(
+ `${prefix}/inferenceClassification/overrides/${outlookAtomicCommon.encodeGraphId(
+ overrideId,
+ )}`,
+ )
+ .patch({ classifyAs });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Updating the Focused Inbox override',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-mail-folder.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-mail-folder.ts
new file mode 100644
index 000000000000..4717c4a48612
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-mail-folder.ts
@@ -0,0 +1,48 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookFullMailFolderActionOutputSchema } from '../output-schemas';
+
+export const outlookUpdateMailFolderAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_update_mail_folder',
+ classification: 'WRITE',
+ displayName: 'Rename Mail Folder',
+ description: 'Renames a mail folder.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Renames a mail folder. The display name is the only writable property of a folder, so use Move Mail Folder to change its parent and Create Mail Folder to add a new one. Idempotent: applying the same name again changes nothing.',
+ idempotent: true,
+ },
+ props: {
+ folderId: Property.ShortText({
+ displayName: 'Folder ID',
+ description: outlookAtomicCommon.wellKnownFolderHint,
+ required: true,
+ }),
+ displayName: Property.ShortText({
+ displayName: 'New Folder Name',
+ required: true,
+ }),
+ },
+ outputSchema: outlookFullMailFolderActionOutputSchema,
+ async run(context) {
+ const { folderId, displayName } = context.propsValue;
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+
+ try {
+ return await client
+ .api(`${prefix}/mailFolders/${outlookAtomicCommon.encodeGraphId(folderId)}`)
+ .patch({ displayName });
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({
+ error,
+ operation: 'Renaming the Outlook mail folder',
+ });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-message.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-message.ts
new file mode 100644
index 000000000000..5edcec0ad28f
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/outlook-update-message.ts
@@ -0,0 +1,150 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { BodyType, FollowupFlagStatus, Importance, Message } from '@microsoft/microsoft-graph-types';
+import { microsoftOutlookAuth } from '../common/auth';
+import { outlookAtomicCommon } from '../common/atomic-common';
+import { outlookCommon } from '../common/client';
+import { outlookMessageActionOutputSchema } from '../output-schemas';
+
+export const outlookUpdateMessageAction = createAction({
+ auth: microsoftOutlookAuth,
+ name: 'outlook_update_message',
+ classification: 'WRITE',
+ displayName: 'Update Message',
+ description: 'Updates the writable properties of a message.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Updates one message in place: read state, flag state, importance and categories on any message, plus subject and body on a draft. Fields left empty are not sent, and categories are merged with the ones already on the message instead of replacing them. Use Mark Message Read / Unread for the read flag alone, or Batch Update Messages for many messages at once. Idempotent: re-applying the same values converges on the same state.',
+ idempotent: true,
+ },
+ props: {
+ messageId: Property.ShortText({
+ displayName: 'Message ID',
+ description: outlookAtomicCommon.messageIdHint,
+ required: true,
+ }),
+ isRead: Property.StaticDropdown({
+ displayName: 'Read State',
+ description: 'Leave unset to keep the current read state.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Mark as read', value: 'true' },
+ { label: 'Mark as unread', value: 'false' },
+ ],
+ },
+ }),
+ flagStatus: Property.StaticDropdown({
+ displayName: 'Flag State',
+ description: 'Leave unset to keep the current flag.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Flagged', value: 'flagged' },
+ { label: 'Not flagged', value: 'notFlagged' },
+ { label: 'Complete', value: 'complete' },
+ ],
+ },
+ }),
+ importance: Property.StaticDropdown({
+ displayName: 'Importance',
+ description: 'Leave unset to keep the current importance.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Low', value: 'low' },
+ { label: 'Normal', value: 'normal' },
+ { label: 'High', value: 'high' },
+ ],
+ },
+ }),
+ categoriesToAdd: Property.Array({
+ displayName: 'Categories to Add',
+ description: 'Category names to add, merged with the categories already on the message.',
+ required: false,
+ }),
+ categoriesToRemove: Property.Array({
+ displayName: 'Categories to Remove',
+ description: 'Category names to remove from the message.',
+ required: false,
+ }),
+ subject: Property.ShortText({
+ displayName: 'Subject',
+ description: 'Only writable while the message is still a draft.',
+ required: false,
+ }),
+ body: Property.LongText({
+ displayName: 'Body',
+ description: 'Only writable while the message is still a draft.',
+ required: false,
+ }),
+ bodyFormat: Property.StaticDropdown({
+ displayName: 'Body Format',
+ description: 'Format of the new body. Ignored when no body is supplied.',
+ required: false,
+ defaultValue: 'text',
+ options: {
+ disabled: false,
+ options: [
+ { label: 'HTML', value: 'html' },
+ { label: 'Text', value: 'text' },
+ ],
+ },
+ }),
+ },
+ outputSchema: outlookMessageActionOutputSchema,
+ async run(context) {
+ const { messageId, isRead, flagStatus, importance, subject, body, bodyFormat } =
+ context.propsValue;
+ const categoriesToAdd = (context.propsValue.categoriesToAdd ?? []) as string[];
+ const categoriesToRemove = (context.propsValue.categoriesToRemove ?? []) as string[];
+
+ const client = outlookCommon.createClient(context.auth);
+ const prefix = outlookCommon.mailboxPrefix(context.auth);
+ const path = `${prefix}/messages/${outlookAtomicCommon.encodeGraphId(messageId)}`;
+
+ const payload: Message = {};
+
+ if (isRead !== undefined) {
+ payload.isRead = isRead === 'true';
+ }
+ if (flagStatus !== undefined) {
+ payload.flag = { flagStatus: flagStatus as FollowupFlagStatus };
+ }
+ if (importance !== undefined) {
+ payload.importance = importance as Importance;
+ }
+ if (subject !== undefined) {
+ payload.subject = subject;
+ }
+ if (body !== undefined) {
+ payload.body = { content: body, contentType: (bodyFormat ?? 'text') as BodyType };
+ }
+
+ const changesCategories = categoriesToAdd.length > 0 || categoriesToRemove.length > 0;
+
+ if (Object.keys(payload).length === 0 && !changesCategories) {
+ throw new Error(
+ 'Updating the Outlook message failed: no fields were supplied, so there is nothing to change.',
+ );
+ }
+
+ try {
+ if (changesCategories) {
+ const current = await client.api(`${path}?$select=id,categories`).get();
+ const existing = (current?.['categories'] ?? []) as string[];
+ const merged = [...new Set([...existing, ...categoriesToAdd])].filter(
+ (category) => !categoriesToRemove.includes(category),
+ );
+ payload.categories = merged;
+ }
+
+ return await client.api(path).headers(outlookAtomicCommon.textBodyHeaders).patch(payload);
+ } catch (error) {
+ throw outlookAtomicCommon.graphError({ error, operation: 'Updating the Outlook message' });
+ }
+ },
+});
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts
index efe826bed8bf..ab1e99160e04 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/remove-label-from-email.ts
@@ -10,7 +10,7 @@ export const removeLabelFromEmailAction = createAction({
classification: 'WRITE',
displayName: 'Remove Label from Email',
description: 'Removes a category (label) from an email message.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Removes one or more Outlook categories (labels) from a specific message, leaving any other categories intact. Use this to untag or reclassify an email. Idempotent: re-running with the same categories yields the same final label set.', idempotent: true },
outputSchema: messageActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts
index 19f7b1f7e1e7..2557bb80def2 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/reply-email.ts
@@ -11,7 +11,7 @@ export const replyEmailAction = createAction({
classification: 'WRITE',
displayName: 'Reply to Email',
description: 'Reply to an outlook email.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Replies to an existing Outlook message (identified by message ID), supporting added CC/BCC recipients and attachments. Set the Create Draft flag to stage the reply without sending; otherwise it is sent immediately. Not idempotent when sending: each call creates and dispatches a new reply.', idempotent: false },
outputSchema: replyEmailActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts
index da207fa374d3..0a1e90ddff32 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-draft-email.ts
@@ -10,7 +10,7 @@ export const sendDraftEmailAction = createAction({
classification: 'WRITE',
displayName: 'Send Draft Email',
description: 'Sends a draft email message.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Sends an existing draft email (identified by draft message ID) from the Outlook mailbox. Use this to dispatch a draft previously staged by Create Draft Email or a draft reply. Not idempotent: once sent the draft no longer exists, so re-running with the same ID will fail.', idempotent: false },
outputSchema: sendDraftEmailActionOutputSchema,
props: {
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts
index 11c5233dd240..4e1d79d34621 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/actions/send-email.ts
@@ -10,7 +10,7 @@ export const sendEmailAction = createAction({
classification: 'WRITE',
displayName: 'Send Email',
description: 'Sends an email using Microsoft Outlook.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Composes and sends a new email from the authenticated Outlook mailbox to the given recipients, with optional CC/BCC and file attachments. Use this to send a fresh message (not a reply or forward). Not idempotent: each call dispatches a new email and saves a copy to Sent Items.', idempotent: false },
props: {
recipients: Property.Array({
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/common/atomic-common.ts b/packages/pieces/community/microsoft-outlook/src/lib/common/atomic-common.ts
new file mode 100644
index 000000000000..5b7017de8228
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/common/atomic-common.ts
@@ -0,0 +1,247 @@
+import { OAuth2PropertyValue } from '@activepieces/pieces-framework';
+import { getGraphBaseUrl, getMicrosoftCloudFromAuth } from './microsoft-cloud';
+
+const textBodyHeaders: Record = {
+ Prefer: 'outlook.body-content-type="text"',
+};
+
+const messageSelect =
+ 'id,subject,bodyPreview,from,sender,toRecipients,ccRecipients,bccRecipients,replyTo,receivedDateTime,sentDateTime,createdDateTime,lastModifiedDateTime,hasAttachments,isRead,isDraft,importance,categories,flag,webLink,parentFolderId,conversationId,internetMessageId';
+
+const mailFolderSelect =
+ 'id,displayName,parentFolderId,childFolderCount,unreadItemCount,totalItemCount,isHidden';
+
+const attachmentSelect = 'id,name,contentType,size,isInline,lastModifiedDateTime';
+
+const wellKnownFolderHint =
+ 'Accepts a mail folder ID or a well-known name such as inbox, drafts, sentitems, deleteditems, archive or junkemail.';
+
+const deltaLinkHint =
+ 'It must be a delta link returned by this action on this connection; links pointing at another mailbox or endpoint are rejected.';
+
+const messageIdHint =
+ 'Outlook message ID. Resolve one with List Messages, Search Messages or Get Message.';
+
+function getConfiguredMailbox(auth: OAuth2PropertyValue): string {
+ return ((auth.props?.['mailbox'] as string | undefined) ?? '').trim();
+}
+
+function isAppOnlyConnection(auth: OAuth2PropertyValue): boolean {
+ return getConfiguredMailbox(auth).length > 0;
+}
+
+function encodeGraphId(id: string): string {
+ return encodeURIComponent(id.trim());
+}
+
+function isGovernmentCloud(auth: OAuth2PropertyValue): boolean {
+ return ((auth.props?.['cloud'] as string | undefined) ?? '').includes('.us');
+}
+
+function graphStatusCode(error: unknown): number | undefined {
+ if (typeof error === 'object' && error !== null && 'statusCode' in error) {
+ const statusCode = (error as { statusCode?: unknown }).statusCode;
+ if (typeof statusCode === 'number') {
+ return statusCode;
+ }
+ }
+ return undefined;
+}
+
+function graphErrorCode(error: unknown): string | undefined {
+ if (typeof error === 'object' && error !== null && 'code' in error) {
+ const code = (error as { code?: unknown }).code;
+ if (typeof code === 'string') {
+ return code;
+ }
+ }
+ return undefined;
+}
+
+function isUnsupportedMePathError(error: unknown): boolean {
+ const code = graphErrorCode(error) ?? '';
+ const unsupportedCodes = [
+ 'BadRequest',
+ 'RequestBroker--ParseUri',
+ 'ResourceNotFound',
+ 'ErrorInvalidUser',
+ 'MailboxNotEnabledForRESTAPI',
+ ];
+ return unsupportedCodes.some((candidate) => code === candidate);
+}
+
+function withDeltaRemoval(
+ change: T & { '@removed'?: { reason?: string } },
+): Omit & { removed: boolean; removedReason: string | null } {
+ const { '@removed': removal, ...rest } = change;
+ return {
+ ...rest,
+ removed: removal !== undefined,
+ removedReason: removal?.reason ?? null,
+ };
+}
+
+function graphErrorDetail(error: unknown): string {
+ if (typeof error === 'object' && error !== null) {
+ const parts: string[] = [];
+ const candidate = error as { code?: unknown; message?: unknown; body?: unknown };
+ if (candidate.code) {
+ parts.push(String(candidate.code));
+ }
+ if (candidate.message) {
+ parts.push(String(candidate.message));
+ }
+ if (candidate.body) {
+ const body = candidate.body;
+ parts.push(typeof body === 'string' ? body.slice(0, 500) : JSON.stringify(body).slice(0, 500));
+ }
+ if (parts.length > 0) {
+ return parts.join(' | ');
+ }
+ }
+ if (error instanceof Error && error.message) {
+ return error.message;
+ }
+ return 'No further detail was returned by Microsoft Graph.';
+}
+
+function graphError({ error, operation }: { error: unknown; operation: string }): Error {
+ const status = graphStatusCode(error);
+ const detail = graphErrorDetail(error);
+ if (status === 401) {
+ return new Error(
+ `${operation} failed: the Outlook connection is not authenticated (HTTP 401). Reconnect the account. ${detail}`,
+ );
+ }
+ if (status === 403) {
+ return new Error(
+ `${operation} failed: Microsoft Graph denied the request (HTTP 403). The connection may lack the permission this endpoint requires, or the mailbox may not be accessible in this access mode. ${detail}`,
+ );
+ }
+ if (status === 404) {
+ return new Error(
+ `${operation} failed: the requested item was not found (HTTP 404). The ID may be wrong, or the item may have been moved or deleted. ${detail}`,
+ );
+ }
+ if (status === 429) {
+ return new Error(
+ `${operation} failed: Microsoft Graph throttled the request (HTTP 429). Retry after a short delay. ${detail}`,
+ );
+ }
+ return new Error(`${operation} failed. ${detail}`);
+}
+
+const GRAPH_API_VERSIONS = ['v1.0', 'beta'];
+
+const DELTA_LINK_SEGMENT_WILDCARD = '*';
+
+function pathSegments(path: string): string[] {
+ return path
+ .split('?')[0]
+ .split('/')
+ .filter((segment) => segment.length > 0)
+ .map((segment) => {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+ });
+}
+
+function segmentsMatchPattern({
+ segments,
+ pattern,
+}: {
+ segments: string[];
+ pattern: string[];
+}): boolean {
+ if (segments.length !== pattern.length) {
+ return false;
+ }
+ return pattern.every((expected, index) =>
+ expected === DELTA_LINK_SEGMENT_WILDCARD
+ ? segments[index].length > 0
+ : segments[index] === expected,
+ );
+}
+
+function resolveDeltaUrl({
+ auth,
+ deltaLink,
+ expectedPath,
+ operation,
+}: {
+ auth: OAuth2PropertyValue;
+ deltaLink: string;
+ expectedPath: string;
+ operation: string;
+}): string {
+ const candidate = deltaLink.trim();
+ const expectedOrigin = new URL(getGraphBaseUrl(getMicrosoftCloudFromAuth(auth))).origin;
+ const expectedPattern = pathSegments(expectedPath);
+ const expectation = `Expected an absolute URL on ${expectedOrigin} whose path is /{${GRAPH_API_VERSIONS.join(
+ '|',
+ )}}/${expectedPattern.join('/')}. Only pass a delta link that this action returned.`;
+
+ let parsed: URL;
+ try {
+ parsed = new URL(candidate);
+ } catch {
+ throw new InvalidDeltaLinkError(
+ `${operation} failed: the supplied delta link is not an absolute URL. ${expectation}`,
+ );
+ }
+
+ if (parsed.origin !== expectedOrigin) {
+ throw new InvalidDeltaLinkError(
+ `${operation} failed: the supplied delta link points at ${parsed.origin}, which is not the Microsoft Graph endpoint of this connection. ${expectation}`,
+ );
+ }
+
+ const segments = pathSegments(parsed.pathname);
+
+ if (segments.length === 0 || !GRAPH_API_VERSIONS.includes(segments[0])) {
+ throw new InvalidDeltaLinkError(
+ `${operation} failed: the supplied delta link does not target a supported Microsoft Graph API version. ${expectation}`,
+ );
+ }
+
+ if (!segmentsMatchPattern({ segments: segments.slice(1), pattern: expectedPattern })) {
+ throw new InvalidDeltaLinkError(
+ `${operation} failed: the supplied delta link addresses /${segments
+ .slice(1)
+ .join('/')}, which is outside the mailbox and endpoint this action is configured for. ${expectation}`,
+ );
+ }
+
+ return parsed.toString();
+}
+
+export class InvalidDeltaLinkError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = 'InvalidDeltaLinkError';
+ }
+}
+
+export const outlookAtomicCommon = {
+ textBodyHeaders,
+ messageSelect,
+ mailFolderSelect,
+ attachmentSelect,
+ wellKnownFolderHint,
+ messageIdHint,
+ getConfiguredMailbox,
+ isAppOnlyConnection,
+ isGovernmentCloud,
+ encodeGraphId,
+ graphStatusCode,
+ graphErrorCode,
+ graphErrorDetail,
+ isUnsupportedMePathError,
+ withDeltaRemoval,
+ resolveDeltaUrl,
+ deltaLinkHint,
+ graphError,
+};
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/common/graph-batch.ts b/packages/pieces/community/microsoft-outlook/src/lib/common/graph-batch.ts
new file mode 100644
index 000000000000..d1ea027bbd78
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/src/lib/common/graph-batch.ts
@@ -0,0 +1,94 @@
+import { Client } from '@microsoft/microsoft-graph-client';
+
+const batchChunkSize = 20;
+const maxRetryDelayMs = 15000;
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function retryDelayMs(response: GraphBatchResponse): number {
+ const retryAfter = response.headers?.['Retry-After'] ?? response.headers?.['retry-after'];
+ const seconds = retryAfter ? Number(retryAfter) : NaN;
+ if (Number.isFinite(seconds) && seconds > 0) {
+ return Math.min(seconds * 1000, maxRetryDelayMs);
+ }
+ return 2000;
+}
+
+async function postChunk({
+ client,
+ requests,
+}: {
+ client: Client;
+ requests: GraphBatchRequest[];
+}): Promise {
+ const response = await client.api('/$batch').post({ requests });
+ const responses = (response?.['responses'] ?? []) as GraphBatchResponse[];
+ return responses;
+}
+
+async function runGraphBatch({
+ client,
+ requests,
+}: {
+ client: Client;
+ requests: GraphBatchRequest[];
+}): Promise {
+ const collected: GraphBatchResponse[] = [];
+
+ for (let index = 0; index < requests.length; index += batchChunkSize) {
+ const chunk = requests.slice(index, index + batchChunkSize);
+ const responses = await postChunk({ client, requests: chunk });
+
+ const throttled = responses.filter((item) => item.status === 429);
+ if (throttled.length === 0) {
+ collected.push(...responses);
+ continue;
+ }
+
+ const waitMs = Math.max(...throttled.map((item) => retryDelayMs(item)));
+ await delay(waitMs);
+
+ const retryIds = new Set(throttled.map((item) => item.id));
+ const retryRequests = chunk.filter((item) => retryIds.has(item.id));
+ const retried = await postChunk({ client, requests: retryRequests });
+ const retriedById = new Map(retried.map((item) => [item.id, item]));
+
+ collected.push(...responses.map((item) => retriedById.get(item.id) ?? item));
+ }
+
+ return collected;
+}
+
+function describeBatchFailure(response: GraphBatchResponse): string {
+ const body = response.body;
+ if (body && typeof body === 'object' && 'error' in body) {
+ const graphError = (body as { error?: { code?: string; message?: string } }).error;
+ const parts = [graphError?.code, graphError?.message].filter(Boolean);
+ if (parts.length > 0) {
+ return parts.join(': ');
+ }
+ }
+ if (typeof body === 'string' && body.length > 0) {
+ return body.slice(0, 500);
+ }
+ return `Microsoft Graph returned HTTP ${response.status} for this item.`;
+}
+
+export type GraphBatchRequest = {
+ id: string;
+ method: string;
+ url: string;
+ body?: unknown;
+ headers?: Record;
+};
+
+export type GraphBatchResponse = {
+ id: string;
+ status: number;
+ body?: unknown;
+ headers?: Record;
+};
+
+export { runGraphBatch, describeBatchFailure };
diff --git a/packages/pieces/community/microsoft-outlook/src/lib/output-schemas.ts b/packages/pieces/community/microsoft-outlook/src/lib/output-schemas.ts
index 75c991c65376..2af6f703b1a1 100644
--- a/packages/pieces/community/microsoft-outlook/src/lib/output-schemas.ts
+++ b/packages/pieces/community/microsoft-outlook/src/lib/output-schemas.ts
@@ -55,6 +55,74 @@ const dispatchResultFields: OutputSchema['fields'] = [
{ key: 'messageId', label: 'Message ID' },
];
+const plainEmailAddressFields: OutputSchema['fields'] = [
+ { key: 'name', label: 'Name' },
+ { key: 'address', label: 'Email Address', format: 'email' },
+];
+
+const graphMessageFields: OutputSchema['fields'] = [
+ ...messageFields,
+ { key: 'inferenceClassification', label: 'Inference Classification' },
+ { key: 'isReadReceiptRequested', label: 'Is Read Receipt Requested', format: 'boolean' },
+ { key: 'isDeliveryReceiptRequested', label: 'Is Delivery Receipt Requested', format: 'boolean' },
+];
+
+const messageSummaryFields: OutputSchema['fields'] = messageFields.filter(
+ (field) => field.key !== 'body',
+);
+
+const graphDraftMessageFields: OutputSchema['fields'] = graphMessageFields.filter(
+ (field) => field.key !== 'from' && field.key !== 'sender',
+);
+
+const mailFolderFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Folder ID' },
+ { key: 'displayName', label: 'Display Name' },
+ { key: 'parentFolderId', label: 'Parent Folder ID' },
+ { key: 'childFolderCount', label: 'Child Folder Count', format: 'number' },
+ { key: 'unreadItemCount', label: 'Unread Item Count', format: 'number' },
+ { key: 'totalItemCount', label: 'Total Item Count', format: 'number' },
+ { key: 'isHidden', label: 'Is Hidden', format: 'boolean' },
+];
+
+const fullMailFolderFields: OutputSchema['fields'] = [
+ ...mailFolderFields,
+ { key: 'sizeInBytes', label: 'Size', format: 'filesize' },
+ { key: 'wellKnownName', label: 'Well-Known Name' },
+];
+
+const graphAttachmentFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Attachment ID' },
+ { key: 'name', label: 'File Name' },
+ { key: 'contentType', label: 'Content Type' },
+ { key: 'size', label: 'Size', format: 'filesize' },
+ { key: 'isInline', label: 'Is Inline', format: 'boolean' },
+ { key: 'lastModifiedDateTime', label: 'Last Modified At', format: 'datetime' },
+];
+
+const batchFailureFields: OutputSchema['fields'] = [
+ { key: 'messageId', label: 'Message ID' },
+ { key: 'status', label: 'HTTP Status', format: 'number' },
+ { key: 'error', label: 'Error' },
+];
+
+const focusedInboxOverrideFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Override ID' },
+ { key: 'classifyAs', label: 'Classify As' },
+ { key: 'senderEmailAddress', label: 'Sender', children: plainEmailAddressFields },
+];
+
+const deltaRemovalFields: OutputSchema['fields'] = [
+ { key: 'removed', label: 'Removed', format: 'boolean' },
+ { key: 'removedReason', label: 'Removal Reason' },
+];
+
+const paginationFields: OutputSchema['fields'] = [
+ { key: 'count', label: 'Count', format: 'number' },
+ { key: 'hasMore', label: 'Has More', format: 'boolean' },
+ { key: 'nextLink', label: 'Next Page URL', format: 'url' },
+];
+
export const messageActionOutputSchema: OutputSchema = { fields: messageFields };
export const draftMessageActionOutputSchema: OutputSchema = {
@@ -105,3 +173,326 @@ export const newAttachmentTriggerOutputSchema: OutputSchema = {
{ key: 'parentFolderId', label: 'Parent Folder ID' },
],
};
+
+export const outlookMessageActionOutputSchema: OutputSchema = { fields: graphMessageFields };
+
+export const outlookDraftMessageActionOutputSchema: OutputSchema = {
+ fields: graphDraftMessageFields,
+};
+
+export const outlookListMessagesActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'messages', label: 'Messages', labelKey: 'subject', listItems: messageSummaryFields },
+ ...paginationFields,
+ ],
+};
+
+export const outlookSearchMessagesActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'found', label: 'Found', format: 'boolean' },
+ { key: 'messages', label: 'Messages', labelKey: 'subject', listItems: graphMessageFields },
+ ...paginationFields,
+ ],
+};
+
+export const outlookListMessagesDeltaActionOutputSchema: OutputSchema = {
+ fields: [
+ {
+ key: 'changes',
+ label: 'Changed Messages',
+ labelKey: 'subject',
+ listItems: [...messageSummaryFields, ...deltaRemovalFields],
+ },
+ ...paginationFields,
+ { key: 'deltaLink', label: 'Delta Link', format: 'url' },
+ ],
+};
+
+export const outlookGetMessageMimeActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'messageId', label: 'Message ID' },
+ { key: 'file', label: 'File', format: 'url' },
+ { key: 'fileName', label: 'File Name' },
+ { key: 'contentType', label: 'Content Type' },
+ { key: 'size', label: 'Size', format: 'filesize' },
+ ],
+};
+
+export const outlookMoveMessageActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'newMessageId', label: 'New Message ID' },
+ { key: 'previousMessageId', label: 'Previous Message ID' },
+ { key: 'destinationFolderId', label: 'Destination Folder ID' },
+ { key: 'message', label: 'Message', children: graphMessageFields },
+ ],
+};
+
+export const outlookCopyMessageActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'newMessageId', label: 'New Message ID' },
+ { key: 'sourceMessageId', label: 'Source Message ID' },
+ { key: 'destinationFolderId', label: 'Destination Folder ID' },
+ { key: 'message', label: 'Message', children: graphMessageFields },
+ ],
+};
+
+export const outlookBatchMoveMessagesActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'destinationFolderId', label: 'Destination Folder ID' },
+ { key: 'requestedCount', label: 'Requested Count', format: 'number' },
+ { key: 'succeededCount', label: 'Succeeded Count', format: 'number' },
+ { key: 'failedCount', label: 'Failed Count', format: 'number' },
+ {
+ key: 'succeeded',
+ label: 'Succeeded',
+ labelKey: 'newMessageId',
+ listItems: [
+ { key: 'messageId', label: 'Previous Message ID' },
+ { key: 'newMessageId', label: 'New Message ID' },
+ ],
+ },
+ { key: 'failed', label: 'Failed', labelKey: 'messageId', listItems: batchFailureFields },
+ ],
+};
+
+export const outlookBatchUpdateMessagesActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'requestedCount', label: 'Requested Count', format: 'number' },
+ { key: 'succeededCount', label: 'Succeeded Count', format: 'number' },
+ { key: 'failedCount', label: 'Failed Count', format: 'number' },
+ {
+ key: 'succeeded',
+ label: 'Succeeded',
+ labelKey: 'messageId',
+ listItems: [{ key: 'messageId', label: 'Message ID' }],
+ },
+ { key: 'failed', label: 'Failed', labelKey: 'messageId', listItems: batchFailureFields },
+ ],
+};
+
+export const outlookDeleteMessageActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'message', label: 'Result Message' },
+ { key: 'messageId', label: 'Message ID' },
+ ],
+};
+
+export const outlookListMessageAttachmentsActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'messageId', label: 'Message ID' },
+ {
+ key: 'attachments',
+ label: 'Attachments',
+ labelKey: 'name',
+ listItems: [
+ ...graphAttachmentFields,
+ { key: 'attachmentType', label: 'Attachment Type' },
+ ],
+ },
+ { key: 'count', label: 'Count', format: 'number' },
+ ],
+};
+
+export const outlookGetMessageAttachmentActionOutputSchema: OutputSchema = {
+ fields: [
+ ...graphAttachmentFields,
+ { key: 'attachmentType', label: 'Attachment Type' },
+ { key: 'messageId', label: 'Message ID' },
+ ],
+};
+
+export const outlookDownloadMessageAttachmentActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'file', label: 'File', format: 'url' },
+ { key: 'fileName', label: 'File Name' },
+ { key: 'contentType', label: 'Content Type' },
+ { key: 'size', label: 'Size', format: 'filesize' },
+ { key: 'attachmentId', label: 'Attachment ID' },
+ { key: 'messageId', label: 'Message ID' },
+ ],
+};
+
+export const outlookAddMessageAttachmentActionOutputSchema: OutputSchema = {
+ fields: [
+ ...graphAttachmentFields,
+ { key: 'attachmentType', label: 'Attachment Type' },
+ { key: 'messageId', label: 'Message ID' },
+ ],
+};
+
+export const outlookDeleteMessageAttachmentActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'message', label: 'Result Message' },
+ { key: 'messageId', label: 'Message ID' },
+ { key: 'attachmentId', label: 'Attachment ID' },
+ ],
+};
+
+export const outlookMailFolderActionOutputSchema: OutputSchema = { fields: mailFolderFields };
+
+export const outlookFullMailFolderActionOutputSchema: OutputSchema = {
+ fields: fullMailFolderFields,
+};
+
+export const outlookListMailFoldersActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'folders', label: 'Folders', labelKey: 'displayName', listItems: mailFolderFields },
+ ...paginationFields,
+ ],
+};
+
+export const outlookListChildMailFoldersActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'parentFolderId', label: 'Parent Folder ID' },
+ { key: 'folders', label: 'Folders', labelKey: 'displayName', listItems: mailFolderFields },
+ ...paginationFields,
+ ],
+};
+
+export const outlookListMailFoldersDeltaActionOutputSchema: OutputSchema = {
+ fields: [
+ {
+ key: 'changes',
+ label: 'Changed Folders',
+ labelKey: 'displayName',
+ listItems: [...fullMailFolderFields, ...deltaRemovalFields],
+ },
+ ...paginationFields,
+ { key: 'deltaLink', label: 'Delta Link', format: 'url' },
+ ],
+};
+
+export const outlookCreateMailFolderActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'created', label: 'Created', format: 'boolean' },
+ { key: 'folderId', label: 'Folder ID' },
+ { key: 'folder', label: 'Folder', children: fullMailFolderFields },
+ ],
+};
+
+export const outlookCopyMailFolderActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'newFolderId', label: 'New Folder ID' },
+ { key: 'sourceFolderId', label: 'Source Folder ID' },
+ { key: 'destinationFolderId', label: 'Destination Folder ID' },
+ { key: 'folder', label: 'Folder', children: fullMailFolderFields },
+ ],
+};
+
+export const outlookMoveMailFolderActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'folderId', label: 'Folder ID' },
+ { key: 'newFolderId', label: 'New Folder ID' },
+ { key: 'destinationFolderId', label: 'Destination Folder ID' },
+ { key: 'folder', label: 'Folder', children: fullMailFolderFields },
+ ],
+};
+
+export const outlookDeleteMailFolderActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'message', label: 'Result Message' },
+ { key: 'folderId', label: 'Folder ID' },
+ ],
+};
+
+export const outlookFocusedInboxOverrideActionOutputSchema: OutputSchema = {
+ fields: focusedInboxOverrideFields,
+};
+
+export const outlookListFocusedInboxOverridesActionOutputSchema: OutputSchema = {
+ fields: [
+ {
+ key: 'overrides',
+ label: 'Overrides',
+ labelKey: 'classifyAs',
+ listItems: focusedInboxOverrideFields,
+ },
+ { key: 'count', label: 'Count', format: 'number' },
+ ],
+};
+
+export const outlookDeleteFocusedInboxOverrideActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'message', label: 'Result Message' },
+ { key: 'overrideId', label: 'Override ID' },
+ ],
+};
+
+export const outlookGetMailTipsActionOutputSchema: OutputSchema = {
+ fields: [
+ {
+ key: 'mailTips',
+ label: 'Mail Tips',
+ listItems: [
+ { key: 'emailAddress', label: 'Email Address', children: plainEmailAddressFields },
+ { key: 'mailboxFull', label: 'Mailbox Full', format: 'boolean' },
+ { key: 'deliveryRestricted', label: 'Delivery Restricted', format: 'boolean' },
+ { key: 'isModerated', label: 'Is Moderated', format: 'boolean' },
+ { key: 'recipientScope', label: 'Recipient Scope' },
+ { key: 'maxMessageSize', label: 'Max Message Size', format: 'filesize' },
+ { key: 'externalMemberCount', label: 'External Member Count', format: 'number' },
+ { key: 'totalMemberCount', label: 'Total Member Count', format: 'number' },
+ { key: 'automaticReplies', label: 'Automatic Replies' },
+ ],
+ },
+ { key: 'count', label: 'Count', format: 'number' },
+ ],
+};
+
+export const outlookGetMyProfileActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'id', label: 'User ID' },
+ { key: 'displayName', label: 'Display Name' },
+ { key: 'mail', label: 'Email Address', format: 'email' },
+ { key: 'userPrincipalName', label: 'User Principal Name' },
+ { key: 'source', label: 'Source' },
+ ],
+};
+
+export const outlookGetSupportedTimeZonesActionOutputSchema: OutputSchema = {
+ fields: [
+ {
+ key: 'timeZones',
+ label: 'Time Zones',
+ labelKey: 'displayName',
+ listItems: [
+ { key: 'alias', label: 'Alias' },
+ { key: 'displayName', label: 'Display Name' },
+ ],
+ },
+ { key: 'count', label: 'Count', format: 'number' },
+ ],
+};
+
+export const outlookSendEmailActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'message', label: 'Result Message' },
+ { key: 'subject', label: 'Subject' },
+ { key: 'recipients', label: 'Recipients' },
+ ],
+};
+
+export const outlookSendDraftActionOutputSchema: OutputSchema = { fields: dispatchResultFields };
+
+export const outlookReplyToMessageActionOutputSchema: OutputSchema = {
+ fields: dispatchResultFields,
+};
+
+export const outlookReplyAllToMessageActionOutputSchema: OutputSchema = {
+ fields: dispatchResultFields,
+};
+
+export const outlookForwardMessageActionOutputSchema: OutputSchema = {
+ fields: [...dispatchResultFields, { key: 'recipients', label: 'Recipients' }],
+};
diff --git a/packages/pieces/community/microsoft-outlook/vitest.config.ts b/packages/pieces/community/microsoft-outlook/vitest.config.ts
new file mode 100644
index 000000000000..d87fc4a69542
--- /dev/null
+++ b/packages/pieces/community/microsoft-outlook/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from 'vitest/config'
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ },
+})
diff --git a/packages/pieces/community/pipedrive/package.json b/packages/pieces/community/pipedrive/package.json
index 95b35e2c27a4..deb995e0262c 100644
--- a/packages/pieces/community/pipedrive/package.json
+++ b/packages/pieces/community/pipedrive/package.json
@@ -1,6 +1,6 @@
{
"name": "@activepieces/piece-pipedrive",
- "version": "0.9.0",
+ "version": "0.10.0",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"scripts": {
diff --git a/packages/pieces/community/pipedrive/src/i18n/translation.json b/packages/pieces/community/pipedrive/src/i18n/translation.json
index 560fa8faa21f..a2415e3e6530 100644
--- a/packages/pieces/community/pipedrive/src/i18n/translation.json
+++ b/packages/pieces/community/pipedrive/src/i18n/translation.json
@@ -225,5 +225,179 @@
"Field to watch for Changes On": "Field to watch for Changes On",
"Stage in Pipeline": "Stage in Pipeline",
"Deal Status": "Deal Status",
- "All(Not Deleted)": "All(Not Deleted)"
-}
\ No newline at end of file
+ "All(Not Deleted)": "All(Not Deleted)",
+ "Get Deal": "Get Deal",
+ "Retrieves a deal by its ID.": "Retrieves a deal by its ID.",
+ "The numeric deal ID (from Search Deals, List Deals or a deal trigger).": "The numeric deal ID (from Search Deals, List Deals or a deal trigger).",
+ "List Deals": "List Deals",
+ "Lists one page of deals, optionally filtered.": "Lists one page of deals, optionally filtered.",
+ "Owner ID": "Owner ID",
+ "Only deals owned by this user ID (from List Users).": "Only deals owned by this user ID (from List Users).",
+ "Only deals linked to this person ID (from Search Persons).": "Only deals linked to this person ID (from Search Persons).",
+ "Only deals linked to this organization ID (from Search Organizations).": "Only deals linked to this organization ID (from Search Organizations).",
+ "Pipeline ID": "Pipeline ID",
+ "Only deals in this pipeline ID (from List Pipelines).": "Only deals in this pipeline ID (from List Pipelines).",
+ "Stage ID": "Stage ID",
+ "Only deals in this stage ID (from List Stages).": "Only deals in this stage ID (from List Stages).",
+ "Only deals with this status. Leave empty for open, won and lost deals.": "Only deals with this status. Leave empty for open, won and lost deals.",
+ "Updated Since": "Updated Since",
+ "Only deals updated at or after this time.": "Only deals updated at or after this time.",
+ "Updated Until": "Updated Until",
+ "Only deals updated before this time.": "Only deals updated before this time.",
+ "Sort By": "Sort By",
+ "Field to sort by. Defaults to ID.": "Field to sort by. Defaults to ID.",
+ "Update Time": "Update Time",
+ "Add Time": "Add Time",
+ "Sort Direction": "Sort Direction",
+ "Sort order. Defaults to ascending.": "Sort order. Defaults to ascending.",
+ "Ascending": "Ascending",
+ "Descending": "Descending",
+ "Limit": "Limit",
+ "Maximum number of items to return in this page (1-500). Defaults to 50.": "Maximum number of items to return in this page (1-500). Defaults to 50.",
+ "Cursor": "Cursor",
+ "The next_cursor value from the previous call, to fetch the next page. Leave empty for the first page.": "The next_cursor value from the previous call, to fetch the next page. Leave empty for the first page.",
+ "Search Deals": "Search Deals",
+ "Searches deals by title, notes or custom field text.": "Searches deals by title, notes or custom field text.",
+ "Text to search for. At least 2 characters, or 1 when Exact Match is Yes.": "Text to search for. At least 2 characters, or 1 when Exact Match is Yes.",
+ "Search In": "Search In",
+ "Fields to search. Leave empty to search all of them.": "Fields to search. Leave empty to search all of them.",
+ "Notes": "Notes",
+ "Yes returns only exact matches of the full term. Leave empty for partial matching.": "Yes returns only exact matches of the full term. Leave empty for partial matching.",
+ "Yes": "Yes",
+ "No": "No",
+ "Only deals with this status. Leave empty for any status.": "Only deals with this status. Leave empty for any status.",
+ "Delete Deal": "Delete Deal",
+ "Deletes a deal.": "Deletes a deal.",
+ "The numeric ID of the deal to delete (from Search Deals or List Deals).": "The numeric ID of the deal to delete (from Search Deals or List Deals).",
+ "List Deal Products": "List Deal Products",
+ "Lists the products attached to a deal.": "Lists the products attached to a deal.",
+ "The numeric deal ID (from Search Deals or List Deals).": "The numeric deal ID (from Search Deals or List Deals).",
+ "Order Number": "Order Number",
+ "Remove Product From Deal": "Remove Product From Deal",
+ "Removes a product line from a deal.": "Removes a product line from a deal.",
+ "The numeric ID of the deal the product is attached to.": "The numeric ID of the deal the product is attached to.",
+ "Deal Product ID": "Deal Product ID",
+ "The id of the row returned by List Deal Products (the attachment ID). This is not the product_id.": "The id of the row returned by List Deal Products (the attachment ID). This is not the product_id.",
+ "Get Person": "Get Person",
+ "Retrieves a person by their ID.": "Retrieves a person by their ID.",
+ "The numeric person ID (from Search Persons, List Persons or a person trigger).": "The numeric person ID (from Search Persons, List Persons or a person trigger).",
+ "List Persons": "List Persons",
+ "Lists one page of persons, optionally filtered.": "Lists one page of persons, optionally filtered.",
+ "Only persons owned by this user ID (from List Users).": "Only persons owned by this user ID (from List Users).",
+ "Only persons linked to this organization ID (from Search Organizations).": "Only persons linked to this organization ID (from Search Organizations).",
+ "Only persons linked to this deal ID (from Search Deals).": "Only persons linked to this deal ID (from Search Deals).",
+ "Only persons updated at or after this time.": "Only persons updated at or after this time.",
+ "Only persons updated before this time.": "Only persons updated before this time.",
+ "Search Persons": "Search Persons",
+ "Searches persons by name, email, phone, notes or custom field text.": "Searches persons by name, email, phone, notes or custom field text.",
+ "Delete Person": "Delete Person",
+ "Deletes a person.": "Deletes a person.",
+ "The numeric ID of the person to delete (from Search Persons or List Persons).": "The numeric ID of the person to delete (from Search Persons or List Persons).",
+ "Get Organization": "Get Organization",
+ "Retrieves an organization by its ID.": "Retrieves an organization by its ID.",
+ "The numeric organization ID (from Search Organizations, List Organizations or an organization trigger).": "The numeric organization ID (from Search Organizations, List Organizations or an organization trigger).",
+ "List Organizations": "List Organizations",
+ "Lists one page of organizations, optionally filtered.": "Lists one page of organizations, optionally filtered.",
+ "Only organizations owned by this user ID (from List Users).": "Only organizations owned by this user ID (from List Users).",
+ "Only organizations updated at or after this time.": "Only organizations updated at or after this time.",
+ "Only organizations updated before this time.": "Only organizations updated before this time.",
+ "Search Organizations": "Search Organizations",
+ "Searches organizations by name, address, notes or custom field text.": "Searches organizations by name, address, notes or custom field text.",
+ "Delete Organization": "Delete Organization",
+ "Deletes an organization.": "Deletes an organization.",
+ "The numeric ID of the organization to delete (from Search Organizations or List Organizations).": "The numeric ID of the organization to delete (from Search Organizations or List Organizations).",
+ "Get Activity": "Get Activity",
+ "Retrieves an activity by its ID.": "Retrieves an activity by its ID.",
+ "The numeric activity ID (from List Activities or an activity trigger).": "The numeric activity ID (from List Activities or an activity trigger).",
+ "List Activities": "List Activities",
+ "Lists one page of activities, optionally filtered.": "Lists one page of activities, optionally filtered.",
+ "Only activities linked to this deal ID (from Search Deals).": "Only activities linked to this deal ID (from Search Deals).",
+ "Only activities linked to this lead ID, a UUID (from Find Lead).": "Only activities linked to this lead ID, a UUID (from Find Lead).",
+ "Only activities linked to this person ID (from Search Persons).": "Only activities linked to this person ID (from Search Persons).",
+ "Only activities linked to this organization ID (from Search Organizations).": "Only activities linked to this organization ID (from Search Organizations).",
+ "Only activities owned by this user ID (from List Users).": "Only activities owned by this user ID (from List Users).",
+ "Yes for completed activities only, No for pending only. Leave empty for both.": "Yes for completed activities only, No for pending only. Leave empty for both.",
+ "Only activities updated at or after this time.": "Only activities updated at or after this time.",
+ "Only activities updated before this time.": "Only activities updated before this time.",
+ "Delete Activity": "Delete Activity",
+ "Deletes an activity.": "Deletes an activity.",
+ "The numeric ID of the activity to delete (from List Activities).": "The numeric ID of the activity to delete (from List Activities).",
+ "Get Lead": "Get Lead",
+ "Retrieves a lead by its ID.": "Retrieves a lead by its ID.",
+ "The lead UUID (from Find Lead or a lead trigger).": "The lead UUID (from Find Lead or a lead trigger).",
+ "Delete Lead": "Delete Lead",
+ "Deletes a lead.": "Deletes a lead.",
+ "The UUID of the lead to delete (from Find Lead).": "The UUID of the lead to delete (from Find Lead).",
+ "Convert Lead to Deal": "Convert Lead to Deal",
+ "Starts converting a lead into a deal.": "Starts converting a lead into a deal.",
+ "The UUID of the lead to convert (from Find Lead).": "The UUID of the lead to convert (from Find Lead).",
+ "Stage for the new deal (from List Stages); its pipeline is used. Set this or Pipeline ID, not both.": "Stage for the new deal (from List Stages); its pipeline is used. Set this or Pipeline ID, not both.",
+ "Pipeline for the new deal (from List Pipelines). Set this or Stage ID, not both.": "Pipeline for the new deal (from List Pipelines). Set this or Stage ID, not both.",
+ "Get Lead Conversion Status": "Get Lead Conversion Status",
+ "Checks the status of a lead-to-deal conversion.": "Checks the status of a lead-to-deal conversion.",
+ "The UUID of the lead that is being converted.": "The UUID of the lead that is being converted.",
+ "Conversion ID": "Conversion ID",
+ "The conversion_id returned by Convert Lead to Deal.": "The conversion_id returned by Convert Lead to Deal.",
+ "Update Note": "Update Note",
+ "Updates an existing note.": "Updates an existing note.",
+ "The numeric ID of the note to update (from Find Notes or Create Note).": "The numeric ID of the note to update (from Find Notes or Create Note).",
+ "New note content. HTML is allowed. Leave empty to keep the current content.": "New note content. HTML is allowed. Leave empty to keep the current content.",
+ "Attach the note to this deal ID (from Search Deals).": "Attach the note to this deal ID (from Search Deals).",
+ "Attach the note to this lead UUID (from Find Lead).": "Attach the note to this lead UUID (from Find Lead).",
+ "Attach the note to this person ID (from Search Persons).": "Attach the note to this person ID (from Search Persons).",
+ "Attach the note to this organization ID (from Search Organizations).": "Attach the note to this organization ID (from Search Organizations).",
+ "Author User ID": "Author User ID",
+ "Make this user ID the note author (from List Users).": "Make this user ID the note author (from List Users).",
+ "Pinned to Deal": "Pinned to Deal",
+ "Yes to pin, No to unpin. Leave empty to keep the current setting.": "Yes to pin, No to unpin. Leave empty to keep the current setting.",
+ "Pinned to Lead": "Pinned to Lead",
+ "Pinned to Person": "Pinned to Person",
+ "Pinned to Organization": "Pinned to Organization",
+ "Delete Note": "Delete Note",
+ "Deletes a note.": "Deletes a note.",
+ "The numeric ID of the note to delete (from Find Notes).": "The numeric ID of the note to delete (from Find Notes).",
+ "Delete Product": "Delete Product",
+ "Deletes a product from the catalog.": "Deletes a product from the catalog.",
+ "The numeric ID of the product to delete (from Find Product or Find Products).": "The numeric ID of the product to delete (from Find Product or Find Products).",
+ "List Pipelines": "List Pipelines",
+ "Lists the sales pipelines.": "Lists the sales pipelines.",
+ "List Stages": "List Stages",
+ "Lists pipeline stages.": "Lists pipeline stages.",
+ "Only stages of this pipeline ID (from List Pipelines). Leave empty for all pipelines.": "Only stages of this pipeline ID (from List Pipelines). Leave empty for all pipelines.",
+ "List Activity Types": "List Activity Types",
+ "Lists the activity types of the company.": "Lists the activity types of the company.",
+ "List Users": "List Users",
+ "Lists the users of the company.": "Lists the users of the company.",
+ "Get Current User": "Get Current User",
+ "Retrieves the connected Pipedrive user.": "Retrieves the connected Pipedrive user.",
+ "Merge Deals": "Merge Deals",
+ "Merge Persons": "Merge Persons",
+ "Merge Organizations": "Merge Organizations",
+ "Merges one deal into another, removing the first.": "Merges one deal into another, removing the first.",
+ "Merges one person into another, removing the first.": "Merges one person into another, removing the first.",
+ "Merges one organization into another, removing the first.": "Merges one organization into another, removing the first.",
+ "Start": "Start",
+ "Activity Type ID": "Activity Type ID",
+ "Target": "Target",
+ "Start Date": "Start Date",
+ "Active": "Active",
+ "Outcome": "Outcome",
+ "User ID": "User ID",
+ "Deal ID To Merge Away (this deal is removed)": "Deal ID To Merge Away (this deal is removed)",
+ "Surviving Deal ID (this deal is kept)": "Surviving Deal ID (this deal is kept)",
+ "Person ID To Merge Away (this person is removed)": "Person ID To Merge Away (this person is removed)",
+ "Surviving Person ID (this person is kept)": "Surviving Person ID (this person is kept)",
+ "Organization ID To Merge Away (this organization is removed)": "Organization ID To Merge Away (this organization is removed)",
+ "Surviving Organization ID (this organization is kept)": "Surviving Organization ID (this organization is kept)",
+ "Zero-based offset of the first item to return. Pass the next_start value from the previous call to fetch the next page. Leave empty for the first page.": "Zero-based offset of the first item to return. Pass the next_start value from the previous call to fetch the next page. Leave empty for the first page.",
+ "Maximum number of items to return in this page (1-50). Defaults to 50.": "Maximum number of items to return in this page (1-50). Defaults to 50.",
+ "The numeric ID of the deal that will be CONSUMED and no longer exist after the merge.": "The numeric ID of the deal that will be CONSUMED and no longer exist after the merge.",
+ "The numeric ID of the deal that SURVIVES the merge and receives the other deal's data.": "The numeric ID of the deal that SURVIVES the merge and receives the other deal's data.",
+ "The numeric ID of the person that will be CONSUMED and no longer exist after the merge.": "The numeric ID of the person that will be CONSUMED and no longer exist after the merge.",
+ "The numeric ID of the person that SURVIVES the merge and will NOT be overwritten.": "The numeric ID of the person that SURVIVES the merge and will NOT be overwritten.",
+ "The numeric ID of the organization that will be CONSUMED and no longer exist after the merge.": "The numeric ID of the organization that will be CONSUMED and no longer exist after the merge.",
+ "The numeric ID of the organization that SURVIVES the merge and will NOT be overwritten.": "The numeric ID of the organization that SURVIVES the merge and will NOT be overwritten.",
+ "Company": "Company",
+ "Sum": "Sum",
+ "Connected": "Connected"
+}
diff --git a/packages/pieces/community/pipedrive/src/index.ts b/packages/pieces/community/pipedrive/src/index.ts
index f8e042764bd6..4b4e8de6c314 100644
--- a/packages/pieces/community/pipedrive/src/index.ts
+++ b/packages/pieces/community/pipedrive/src/index.ts
@@ -1,5 +1,5 @@
import { createCustomApiCallAction } from '@activepieces/pieces-common';
-import { OAuth2PropertyValue, PieceAuth, createPiece } from '@activepieces/pieces-framework';
+import { OAuth2PropertyValue, createPiece } from '@activepieces/pieces-framework';
import { PieceCategory } from '@activepieces/pieces-framework';
import { newActivity } from './lib/trigger/new-activity';
import { newDeal } from './lib/trigger/new-deal';
@@ -44,13 +44,45 @@ import { findDealAction } from './lib/actions/find-deal';
import { findActivityAction } from './lib/actions/find-activity';
import { updateProductAction } from './lib/actions/update-product';
import { findLeadAction } from './lib/actions/find-leads';
+import { getDealAction } from './lib/actions/get-deal';
+import { listDealsAction } from './lib/actions/list-deals';
+import { searchDealsAction } from './lib/actions/search-deals';
+import { deleteDealAction } from './lib/actions/delete-deal';
+import { listDealProductsAction } from './lib/actions/list-deal-products';
+import { removeProductFromDealAction } from './lib/actions/remove-product-from-deal';
+import { getPersonAction } from './lib/actions/get-person';
+import { listPersonsAction } from './lib/actions/list-persons';
+import { searchPersonsAction } from './lib/actions/search-persons';
+import { deletePersonAction } from './lib/actions/delete-person';
+import { getOrganizationAction } from './lib/actions/get-organization';
+import { listOrganizationsAction } from './lib/actions/list-organizations';
+import { searchOrganizationsAction } from './lib/actions/search-organizations';
+import { deleteOrganizationAction } from './lib/actions/delete-organization';
+import { getActivityAction } from './lib/actions/get-activity';
+import { listActivitiesAction } from './lib/actions/list-activities';
+import { deleteActivityAction } from './lib/actions/delete-activity';
+import { getLeadAction } from './lib/actions/get-lead';
+import { deleteLeadAction } from './lib/actions/delete-lead';
+import { convertLeadToDealAction } from './lib/actions/convert-lead-to-deal';
+import { getLeadConversionStatusAction } from './lib/actions/get-lead-conversion-status';
+import { updateNoteAction } from './lib/actions/update-note';
+import { deleteNoteAction } from './lib/actions/delete-note';
+import { deleteProductAction } from './lib/actions/delete-product';
+import { listPipelinesAction } from './lib/actions/list-pipelines';
+import { listStagesAction } from './lib/actions/list-stages';
+import { listActivityTypesAction } from './lib/actions/list-activity-types';
+import { listUsersAction } from './lib/actions/list-users';
+import { getCurrentUserAction } from './lib/actions/get-current-user';
+import { mergeDealsAction } from './lib/actions/merge-deals';
+import { mergePersonsAction } from './lib/actions/merge-persons';
+import { mergeOrganizationsAction } from './lib/actions/merge-organizations';
import { pipedriveAuth } from './lib/auth';
export const pipedrive = createPiece({
displayName: 'Pipedrive',
description: 'Sales CRM and pipeline management software',
- minimumSupportedRelease: '0.30.0',
+ minimumSupportedRelease: '0.88.2',
logoUrl: 'https://cdn.activepieces.com/pieces/pipedrive.png',
categories: [PieceCategory.SALES_AND_CRM],
auth: pipedriveAuth,
@@ -84,8 +116,45 @@ export const pipedrive = createPiece({
findActivityAction,
findUserAction,
findLeadAction,
+ getDealAction,
+ listDealsAction,
+ searchDealsAction,
+ deleteDealAction,
+ listDealProductsAction,
+ removeProductFromDealAction,
+ getPersonAction,
+ listPersonsAction,
+ searchPersonsAction,
+ deletePersonAction,
+ getOrganizationAction,
+ listOrganizationsAction,
+ searchOrganizationsAction,
+ deleteOrganizationAction,
+ getActivityAction,
+ listActivitiesAction,
+ deleteActivityAction,
+ getLeadAction,
+ deleteLeadAction,
+ convertLeadToDealAction,
+ getLeadConversionStatusAction,
+ updateNoteAction,
+ deleteNoteAction,
+ deleteProductAction,
+ listPipelinesAction,
+ listStagesAction,
+ listActivityTypesAction,
+ listUsersAction,
+ getCurrentUserAction,
+ mergeDealsAction,
+ mergePersonsAction,
+ mergeOrganizationsAction,
createCustomApiCallAction({
- baseUrl: () => 'https://api.pipedrive.com/api/v2',
+ baseUrl: (auth) => {
+ const apiDomain = auth?.data?.['api_domain'];
+ return typeof apiDomain === 'string' && apiDomain.length > 0
+ ? `${apiDomain}/api/v2`
+ : 'https://api.pipedrive.com/api/v2';
+ },
auth: pipedriveAuth,
authMapping: async (auth) => ({
Authorization: `Bearer ${(auth as OAuth2PropertyValue).access_token}`,
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/convert-lead-to-deal.ts b/packages/pieces/community/pipedrive/src/lib/actions/convert-lead-to-deal.ts
new file mode 100644
index 000000000000..b6d1b7433268
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/convert-lead-to-deal.ts
@@ -0,0 +1,62 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, isNil, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { convertLeadToDealActionOutputSchema } from '../output-schemas';
+
+export const convertLeadToDealAction = createAction({
+ auth: pipedriveAuth,
+ name: 'convert-lead-to-deal',
+ outputSchema: convertLeadToDealActionOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Convert Lead to Deal',
+ description: 'Starts converting a lead into a deal.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Starts an asynchronous conversion of a lead into a deal and returns a conversion_id, not the deal. The new deal_id comes from Get Lead Conversion Status (call it with the lead ID and conversion_id until status is completed). On success Pipedrive deletes the lead. Set Stage ID or Pipeline ID, not both (Pipedrive ignores the pipeline when a stage is given); leave both empty to let Pipedrive pick the placement. Not safe to retry: poll the status instead of calling this again.',
+ idempotent: false,
+ },
+ props: {
+ leadId: Property.ShortText({
+ displayName: 'Lead ID',
+ description: 'The UUID of the lead to convert (from Find Lead).',
+ required: true,
+ }),
+ stageId: Property.Number({
+ displayName: 'Stage ID',
+ description:
+ 'Stage for the new deal (from List Stages); its pipeline is used. Set this or Pipeline ID, not both.',
+ required: false,
+ }),
+ pipelineId: Property.Number({
+ displayName: 'Pipeline ID',
+ description:
+ 'Pipeline for the new deal (from List Pipelines). Set this or Stage ID, not both.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const { stageId, pipelineId } = context.propsValue;
+ if (!isNil(stageId) && !isNil(pipelineId)) {
+ throw new Error(
+ 'Set either Stage ID or Pipeline ID, not both: Pipedrive ignores Pipeline ID when Stage ID is given.',
+ );
+ }
+ const leadId = context.propsValue.leadId.trim();
+ const response = await pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.POST,
+ resourceUri: `/v2/leads/${encodeURIComponent(leadId)}/convert/deal`,
+ resourceLabel: `Lead ${leadId}`,
+ body: {
+ stage_id: stageId,
+ pipeline_id: pipelineId,
+ },
+ });
+ return {
+ conversion_id: response.data.conversion_id,
+ lead_id: leadId,
+ };
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/delete-activity.ts b/packages/pieces/community/pipedrive/src/lib/actions/delete-activity.ts
new file mode 100644
index 000000000000..283ef763f52d
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/delete-activity.ts
@@ -0,0 +1,36 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteRecordActionOutputSchema } from '../output-schemas';
+
+export const deleteActivityAction = createAction({
+ auth: pipedriveAuth,
+ name: 'delete-activity',
+ outputSchema: deleteRecordActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Activity',
+ description: 'Deletes an activity.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one activity by numeric ID. The activity is soft-deleted; Pipedrive purges it after 30 days and it cannot be restored via this piece. To mark an activity complete instead, use Update Activity. Not safe to retry blindly.',
+ idempotent: false,
+ },
+ props: {
+ activityId: Property.Number({
+ displayName: 'Activity ID',
+ description: 'The numeric ID of the activity to delete (from List Activities).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { activityId } = context.propsValue;
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v2/activities/${activityId}`,
+ resourceLabel: `Activity ${activityId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/delete-deal.ts b/packages/pieces/community/pipedrive/src/lib/actions/delete-deal.ts
new file mode 100644
index 000000000000..7e31af481ea8
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/delete-deal.ts
@@ -0,0 +1,36 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteRecordActionOutputSchema } from '../output-schemas';
+
+export const deleteDealAction = createAction({
+ auth: pipedriveAuth,
+ name: 'delete-deal',
+ outputSchema: deleteRecordActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Deal',
+ description: 'Deletes a deal.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one deal by its numeric ID. The deal is soft-deleted; Pipedrive purges it after 30 days and it cannot be restored via this piece. To close a deal as won or lost instead, use Update Deal. Not safe to retry blindly.',
+ idempotent: false,
+ },
+ props: {
+ dealId: Property.Number({
+ displayName: 'Deal ID',
+ description: 'The numeric ID of the deal to delete (from Search Deals or List Deals).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { dealId } = context.propsValue;
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v2/deals/${dealId}`,
+ resourceLabel: `Deal ${dealId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/delete-lead.ts b/packages/pieces/community/pipedrive/src/lib/actions/delete-lead.ts
new file mode 100644
index 000000000000..1d930d13103a
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/delete-lead.ts
@@ -0,0 +1,36 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteRecordWithAdditionalDataActionOutputSchema } from '../output-schemas';
+
+export const deleteLeadAction = createAction({
+ auth: pipedriveAuth,
+ name: 'delete-lead',
+ outputSchema: deleteRecordWithAdditionalDataActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Lead',
+ description: 'Deletes a lead.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one lead by its UUID; it cannot be restored via this piece. To turn a lead into a deal instead, use Convert Lead to Deal; to archive it, use Update Lead. Safe to retry: a repeat call on an already deleted record also succeeds.',
+ idempotent: true,
+ },
+ props: {
+ leadId: Property.ShortText({
+ displayName: 'Lead ID',
+ description: 'The UUID of the lead to delete (from Find Lead).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const leadId = encodeURIComponent(context.propsValue.leadId.trim());
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v1/leads/${leadId}`,
+ resourceLabel: `Lead ${context.propsValue.leadId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/delete-note.ts b/packages/pieces/community/pipedrive/src/lib/actions/delete-note.ts
new file mode 100644
index 000000000000..61572bf3b72b
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/delete-note.ts
@@ -0,0 +1,36 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteNoteActionOutputSchema } from '../output-schemas';
+
+export const deleteNoteAction = createAction({
+ auth: pipedriveAuth,
+ name: 'delete-note',
+ outputSchema: deleteNoteActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Note',
+ description: 'Deletes a note.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one note by numeric ID; it cannot be restored via this piece. To change a note\'s text or pins instead, use Update Note. Not safe to retry blindly.',
+ idempotent: false,
+ },
+ props: {
+ noteId: Property.Number({
+ displayName: 'Note ID',
+ description: 'The numeric ID of the note to delete (from Find Notes).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { noteId } = context.propsValue;
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v1/notes/${noteId}`,
+ resourceLabel: `Note ${noteId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/delete-organization.ts b/packages/pieces/community/pipedrive/src/lib/actions/delete-organization.ts
new file mode 100644
index 000000000000..7fcf500bc1a8
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/delete-organization.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteRecordActionOutputSchema } from '../output-schemas';
+
+export const deleteOrganizationAction = createAction({
+ auth: pipedriveAuth,
+ name: 'delete-organization',
+ outputSchema: deleteRecordActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Organization',
+ description: 'Deletes an organization.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one organization by numeric ID. The organization is soft-deleted; Pipedrive purges it after 30 days and it cannot be restored via this piece. Not safe to retry blindly.',
+ idempotent: false,
+ },
+ props: {
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description:
+ 'The numeric ID of the organization to delete (from Search Organizations or List Organizations).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { organizationId } = context.propsValue;
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v2/organizations/${organizationId}`,
+ resourceLabel: `Organization ${organizationId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/delete-person.ts b/packages/pieces/community/pipedrive/src/lib/actions/delete-person.ts
new file mode 100644
index 000000000000..cacc43764885
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/delete-person.ts
@@ -0,0 +1,36 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteRecordActionOutputSchema } from '../output-schemas';
+
+export const deletePersonAction = createAction({
+ auth: pipedriveAuth,
+ name: 'delete-person',
+ outputSchema: deleteRecordActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Person',
+ description: 'Deletes a person.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one person (contact) by numeric ID. The person is soft-deleted; Pipedrive purges it after 30 days and it cannot be restored via this piece. Not safe to retry blindly.',
+ idempotent: false,
+ },
+ props: {
+ personId: Property.Number({
+ displayName: 'Person ID',
+ description: 'The numeric ID of the person to delete (from Search Persons or List Persons).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { personId } = context.propsValue;
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v2/persons/${personId}`,
+ resourceLabel: `Person ${personId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/delete-product.ts b/packages/pieces/community/pipedrive/src/lib/actions/delete-product.ts
new file mode 100644
index 000000000000..7458fe247c2b
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/delete-product.ts
@@ -0,0 +1,36 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteRecordWithAdditionalDataActionOutputSchema } from '../output-schemas';
+
+export const deleteProductAction = createAction({
+ auth: pipedriveAuth,
+ name: 'delete-product',
+ outputSchema: deleteRecordWithAdditionalDataActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Product',
+ description: 'Deletes a product from the catalog.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Deletes one product from the product catalog by numeric ID. The product is soft-deleted; Pipedrive purges it after 30 days and it cannot be restored via this piece. To detach a product from a single deal instead, use Remove Product From Deal. Safe to retry: a repeat call on an already deleted record also succeeds.',
+ idempotent: true,
+ },
+ props: {
+ productId: Property.Number({
+ displayName: 'Product ID',
+ description: 'The numeric ID of the product to delete (from Find Product or Find Products).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { productId } = context.propsValue;
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v2/products/${productId}`,
+ resourceLabel: `Product ${productId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/find-deal.ts b/packages/pieces/community/pipedrive/src/lib/actions/find-deal.ts
index a49005060184..e9059f9c4879 100644
--- a/packages/pieces/community/pipedrive/src/lib/actions/find-deal.ts
+++ b/packages/pieces/community/pipedrive/src/lib/actions/find-deal.ts
@@ -19,7 +19,7 @@ export const findDealAction = createAction({
classification: 'SEARCH',
displayName: 'Find Deal',
description: 'Finds a deal by any field.',
- audience: 'both',
+ audience: 'human',
aiMetadata: {
description:
'Finds a single deal by matching a chosen field to a value (exact match), returning the most recently updated match. Use to locate a deal before reading or updating it; pick the field via Search Field and supply its value. To list deals tied to a specific person, use Find Deals Associated With Person instead. Read-only and idempotent (a temporary search filter is created and removed internally).',
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/find-deals-associated-with-person.ts b/packages/pieces/community/pipedrive/src/lib/actions/find-deals-associated-with-person.ts
index 31bcdf8e31cb..99ccca721ac8 100644
--- a/packages/pieces/community/pipedrive/src/lib/actions/find-deals-associated-with-person.ts
+++ b/packages/pieces/community/pipedrive/src/lib/actions/find-deals-associated-with-person.ts
@@ -19,7 +19,7 @@ export const findDealsAssociatedWithPersonAction = createAction({
classification: 'SEARCH',
displayName: 'Find Deals Associated With Person',
description: 'Finds multiple deals related to a specific person.',
- audience: 'both',
+ audience: 'human',
aiMetadata: {
description:
'Lists all deals linked to a given person, identified by their numeric person ID, sorted by most recently updated. Use when you already have a person and need their associated deals; to look up a deal by a field value instead, use Find Deal. Read-only and idempotent.',
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/find-organization.ts b/packages/pieces/community/pipedrive/src/lib/actions/find-organization.ts
index 75c6d74b7bce..df095ea4ddfc 100644
--- a/packages/pieces/community/pipedrive/src/lib/actions/find-organization.ts
+++ b/packages/pieces/community/pipedrive/src/lib/actions/find-organization.ts
@@ -19,7 +19,7 @@ export const findOrganizationAction = createAction({
classification: 'SEARCH',
displayName: 'Find Organization',
description: 'Finds an organization.',
- audience: 'both',
+ audience: 'human',
aiMetadata: {
description:
'Finds a single organization by matching a chosen field to a value (exact match), returning the most recently updated match. Use to locate an organization before reading or updating it; pick the field via Search Field and supply its value. Read-only and idempotent (a temporary search filter is created and removed internally).',
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/find-person.ts b/packages/pieces/community/pipedrive/src/lib/actions/find-person.ts
index a6236b97a7f0..67dc42e0fbb2 100644
--- a/packages/pieces/community/pipedrive/src/lib/actions/find-person.ts
+++ b/packages/pieces/community/pipedrive/src/lib/actions/find-person.ts
@@ -19,7 +19,7 @@ export const findPersonAction = createAction({
classification: 'SEARCH',
displayName: 'Find Person',
description: 'Finds a person.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Look up a single person by an exact value in a chosen field (e.g. email, phone, name, or a custom field). Pick this when you need to resolve a person record to its ID or details before acting on it. Effectively read-only (it creates and removes a temporary search filter) and returns the most recently updated match.', idempotent: true },
props: {
searchField: searchFieldProp('person'),
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/get-activity.ts b/packages/pieces/community/pipedrive/src/lib/actions/get-activity.ts
new file mode 100644
index 000000000000..40d3cbf6f304
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/get-activity.ts
@@ -0,0 +1,37 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { getActivityActionOutputSchema } from '../output-schemas';
+
+export const getActivityAction = createAction({
+ auth: pipedriveAuth,
+ name: 'get-activity',
+ outputSchema: getActivityActionOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Activity',
+ description: 'Retrieves an activity by its ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads one activity (call, meeting, task, etc.) by numeric ID, including its attendees and linked deal, lead, person and organization. Use when you already hold the activity ID; to browse activities of a deal or person use List Activities, and for an exact single-field match use Find Activity. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ activityId: Property.Number({
+ displayName: 'Activity ID',
+ description: 'The numeric activity ID (from List Activities or an activity trigger).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { activityId } = context.propsValue;
+ return pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: `/v2/activities/${activityId}`,
+ resourceLabel: `Activity ${activityId}`,
+ query: { include_fields: 'attendees' },
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/get-current-user.ts b/packages/pieces/community/pipedrive/src/lib/actions/get-current-user.ts
new file mode 100644
index 000000000000..ebe9f7d0aecf
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/get-current-user.ts
@@ -0,0 +1,29 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { getCurrentUserActionOutputSchema } from '../output-schemas';
+
+export const getCurrentUserAction = createAction({
+ auth: pipedriveAuth,
+ name: 'get-current-user',
+ outputSchema: getCurrentUserActionOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Current User',
+ description: 'Retrieves the connected Pipedrive user.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns the user this connection acts as, with user ID, email, timezone, default currency and company details. Use it for "me" or "my deals" requests to get your own Owner ID; use List Users for teammates. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {},
+ async run(context) {
+ return pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v1/users/me',
+ resourceLabel: 'the connected user',
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/get-deal.ts b/packages/pieces/community/pipedrive/src/lib/actions/get-deal.ts
new file mode 100644
index 000000000000..5217959cdd84
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/get-deal.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { pipedriveTransformCustomFields } from '../common';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { DEAL_OPTIONAL_FIELDS } from '../common/constants';
+import { getDealActionOutputSchema } from '../output-schemas';
+
+export const getDealAction = createAction({
+ auth: pipedriveAuth,
+ name: 'get-deal',
+ outputSchema: getDealActionOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Deal',
+ description: 'Retrieves a deal by its ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads one deal by its numeric ID, including activity/product counters and custom fields by their names. Use when you already hold the deal ID; to locate a deal by text use Search Deals, and for an exact single-field match use Find Deal. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ dealId: Property.Number({
+ displayName: 'Deal ID',
+ description: 'The numeric deal ID (from Search Deals, List Deals or a deal trigger).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { dealId } = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: `/v2/deals/${dealId}`,
+ resourceLabel: `Deal ${dealId}`,
+ query: { include_fields: DEAL_OPTIONAL_FIELDS.join(',') },
+ });
+ const customFields = await pipedriveAtomic.fetchCustomFieldDefinitions({
+ auth: context.auth,
+ resourceUri: '/v1/dealFields',
+ });
+ return {
+ success: response.success,
+ data: pipedriveTransformCustomFields(customFields, response.data),
+ };
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/get-lead-conversion-status.ts b/packages/pieces/community/pipedrive/src/lib/actions/get-lead-conversion-status.ts
new file mode 100644
index 000000000000..ac154ea837f1
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/get-lead-conversion-status.ts
@@ -0,0 +1,49 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { getLeadConversionStatusActionOutputSchema } from '../output-schemas';
+
+export const getLeadConversionStatusAction = createAction({
+ auth: pipedriveAuth,
+ name: 'get-lead-conversion-status',
+ outputSchema: getLeadConversionStatusActionOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Lead Conversion Status',
+ description: 'Checks the status of a lead-to-deal conversion.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads the current status of a conversion started by Convert Lead to Deal: not_started, running, completed, failed or rejected. When completed, deal_id holds the new deal (read it with Get Deal). Makes one check per call; call again after a short wait while running. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ leadId: Property.ShortText({
+ displayName: 'Lead ID',
+ description: 'The UUID of the lead that is being converted.',
+ required: true,
+ }),
+ conversionId: Property.ShortText({
+ displayName: 'Conversion ID',
+ description: 'The conversion_id returned by Convert Lead to Deal.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const leadId = encodeURIComponent(context.propsValue.leadId.trim());
+ const conversionId = encodeURIComponent(context.propsValue.conversionId.trim());
+ return pipedriveAtomic.call<
+ PipedriveEnvelope<{
+ conversion_id: string;
+ status: string;
+ deal_id?: number;
+ lead_id?: string;
+ }>
+ >({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: `/v2/leads/${leadId}/convert/status/${conversionId}`,
+ resourceLabel: `Conversion ${context.propsValue.conversionId} of lead ${context.propsValue.leadId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/get-lead.ts b/packages/pieces/community/pipedrive/src/lib/actions/get-lead.ts
new file mode 100644
index 000000000000..4c3f7e4efe78
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/get-lead.ts
@@ -0,0 +1,36 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { getLeadActionOutputSchema } from '../output-schemas';
+
+export const getLeadAction = createAction({
+ auth: pipedriveAuth,
+ name: 'get-lead',
+ outputSchema: getLeadActionOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Lead',
+ description: 'Retrieves a lead by its ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads one lead by its UUID, including title, value, owner, linked person/organization and labels. Use when you already hold the lead ID; to locate a lead by text use Find Lead. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ leadId: Property.ShortText({
+ displayName: 'Lead ID',
+ description: 'The lead UUID (from Find Lead or a lead trigger).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const leadId = encodeURIComponent(context.propsValue.leadId.trim());
+ return pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: `/v1/leads/${leadId}`,
+ resourceLabel: `Lead ${context.propsValue.leadId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/get-organization.ts b/packages/pieces/community/pipedrive/src/lib/actions/get-organization.ts
new file mode 100644
index 000000000000..25d61f7a0844
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/get-organization.ts
@@ -0,0 +1,48 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { pipedriveTransformCustomFields } from '../common';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { ORGANIZATION_OPTIONAL_FIELDS } from '../common/constants';
+import { getOrganizationActionOutputSchema } from '../output-schemas';
+
+export const getOrganizationAction = createAction({
+ auth: pipedriveAuth,
+ name: 'get-organization',
+ outputSchema: getOrganizationActionOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Organization',
+ description: 'Retrieves an organization by its ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads one organization by numeric ID, including people/deal/activity counters and custom fields by their names. Use when you already hold the organization ID; to locate one by name use Search Organizations, and for an exact single-field match use Find Organization. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description:
+ 'The numeric organization ID (from Search Organizations, List Organizations or an organization trigger).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { organizationId } = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: `/v2/organizations/${organizationId}`,
+ resourceLabel: `Organization ${organizationId}`,
+ query: { include_fields: ORGANIZATION_OPTIONAL_FIELDS.join(',') },
+ });
+ const customFields = await pipedriveAtomic.fetchCustomFieldDefinitions({
+ auth: context.auth,
+ resourceUri: '/v1/organizationFields',
+ });
+ return {
+ success: response.success,
+ data: pipedriveTransformCustomFields(customFields, response.data),
+ };
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/get-person.ts b/packages/pieces/community/pipedrive/src/lib/actions/get-person.ts
new file mode 100644
index 000000000000..3a20f7a2450d
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/get-person.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { pipedriveTransformCustomFields } from '../common';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { PERSON_OPTIONAL_FIELDS } from '../common/constants';
+import { getPersonActionOutputSchema } from '../output-schemas';
+
+export const getPersonAction = createAction({
+ auth: pipedriveAuth,
+ name: 'get-person',
+ outputSchema: getPersonActionOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Person',
+ description: 'Retrieves a person by their ID.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Reads one person (contact) by numeric ID, including emails, phones, deal/activity counters and custom fields by their names. Use when you already hold the person ID; to locate a person by name, email or phone use Search Persons, and for an exact single-field match use Find Person. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ personId: Property.Number({
+ displayName: 'Person ID',
+ description: 'The numeric person ID (from Search Persons, List Persons or a person trigger).',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { personId } = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: `/v2/persons/${personId}`,
+ resourceLabel: `Person ${personId}`,
+ query: { include_fields: PERSON_OPTIONAL_FIELDS.join(',') },
+ });
+ const customFields = await pipedriveAtomic.fetchCustomFieldDefinitions({
+ auth: context.auth,
+ resourceUri: '/v1/personFields',
+ });
+ return {
+ success: response.success,
+ data: pipedriveTransformCustomFields(customFields, response.data),
+ };
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-activities.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-activities.ts
new file mode 100644
index 000000000000..efaf3cc207da
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-activities.ts
@@ -0,0 +1,109 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2ListResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { listActivitiesActionOutputSchema } from '../output-schemas';
+
+export const listActivitiesAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-activities',
+ outputSchema: listActivitiesActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Activities',
+ description: 'Lists one page of activities, optionally filtered.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns one bounded page of activities filtered by deal, lead, person, organization, owner, done state or update time; pass next_cursor back as Cursor for the next page. Use this to see what is scheduled or done for a record; Find Activity instead does an exact match on one chosen field. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ dealId: Property.Number({
+ displayName: 'Deal ID',
+ description: 'Only activities linked to this deal ID (from Search Deals).',
+ required: false,
+ }),
+ leadId: Property.ShortText({
+ displayName: 'Lead ID',
+ description: 'Only activities linked to this lead ID, a UUID (from Find Lead).',
+ required: false,
+ }),
+ personId: Property.Number({
+ displayName: 'Person ID',
+ description: 'Only activities linked to this person ID (from Search Persons).',
+ required: false,
+ }),
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description: 'Only activities linked to this organization ID (from Search Organizations).',
+ required: false,
+ }),
+ ownerId: Property.Number({
+ displayName: 'Owner ID',
+ description: 'Only activities owned by this user ID (from List Users).',
+ required: false,
+ }),
+ done: Property.StaticDropdown({
+ displayName: 'Done',
+ description: 'Yes for completed activities only, No for pending only. Leave empty for both.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Yes', value: true },
+ { label: 'No', value: false },
+ ],
+ },
+ }),
+ updatedSince: Property.DateTime({
+ displayName: 'Updated Since',
+ description: 'Only activities updated at or after this time.',
+ required: false,
+ }),
+ updatedUntil: Property.DateTime({
+ displayName: 'Updated Until',
+ description: 'Only activities updated before this time.',
+ required: false,
+ }),
+ sortBy: Property.StaticDropdown({
+ displayName: 'Sort By',
+ description: 'Field to sort by. Defaults to ID.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'ID', value: 'id' },
+ { label: 'Update Time', value: 'update_time' },
+ { label: 'Add Time', value: 'add_time' },
+ { label: 'Due Date', value: 'due_date' },
+ ],
+ },
+ }),
+ sortDirection: pipedriveAtomic.sortDirectionProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/activities',
+ resourceLabel: 'activities',
+ query: {
+ deal_id: props.dealId,
+ lead_id: pipedriveAtomic.emptyToUndefined(props.leadId),
+ person_id: props.personId,
+ org_id: props.organizationId,
+ owner_id: props.ownerId,
+ done: props.done,
+ updated_since: pipedriveAtomic.toRfc3339({ value: props.updatedSince, label: 'Updated Since' }),
+ updated_until: pipedriveAtomic.toRfc3339({ value: props.updatedUntil, label: 'Updated Until' }),
+ sort_by: props.sortBy,
+ sort_direction: props.sortDirection,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.toPage(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-activity-types.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-activity-types.ts
new file mode 100644
index 000000000000..4528e0c4572e
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-activity-types.ts
@@ -0,0 +1,31 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { listActivityTypesActionOutputSchema } from '../output-schemas';
+
+export const listActivityTypesAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-activity-types',
+ outputSchema: listActivityTypesActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Activity Types',
+ description: 'Lists the activity types of the company.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists every activity type (call, meeting, task, custom types) with its name and key_string. The key_string is the type value that Create Activity and Update Activity expect. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {},
+ async run(context) {
+ const response = await pipedriveAtomic.call[] | null>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v1/activityTypes',
+ resourceLabel: 'activity types',
+ });
+ const data = response.data ?? [];
+ return { found: data.length > 0, data };
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-deal-products.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-deal-products.ts
new file mode 100644
index 000000000000..f048eb7fd9c7
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-deal-products.ts
@@ -0,0 +1,59 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2ListResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { listDealProductsActionOutputSchema } from '../output-schemas';
+
+export const listDealProductsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-deal-products',
+ outputSchema: listDealProductsActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Deal Products',
+ description: 'Lists the products attached to a deal.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns one page of the product lines attached to a deal, with price, quantity and discount; pass next_cursor back as Cursor for the next page. Each row\'s id is the attachment ID that Remove Product From Deal needs, which differs from its product_id. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ dealId: Property.Number({
+ displayName: 'Deal ID',
+ description: 'The numeric deal ID (from Search Deals or List Deals).',
+ required: true,
+ }),
+ sortBy: Property.StaticDropdown({
+ displayName: 'Sort By',
+ description: 'Field to sort by. Defaults to ID.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'ID', value: 'id' },
+ { label: 'Add Time', value: 'add_time' },
+ { label: 'Update Time', value: 'update_time' },
+ { label: 'Order Number', value: 'order_nr' },
+ ],
+ },
+ }),
+ sortDirection: pipedriveAtomic.sortDirectionProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: `/v2/deals/${props.dealId}/products`,
+ resourceLabel: `Deal ${props.dealId}`,
+ query: {
+ sort_by: props.sortBy,
+ sort_direction: props.sortDirection,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.toPage(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-deals.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-deals.ts
new file mode 100644
index 000000000000..19356054dc47
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-deals.ts
@@ -0,0 +1,110 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2ListResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { listDealsActionOutputSchema } from '../output-schemas';
+
+export const listDealsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-deals',
+ outputSchema: listDealsActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Deals',
+ description: 'Lists one page of deals, optionally filtered.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns one bounded page of deals filtered by owner, person, organization, pipeline, stage, status or update time; pass next_cursor back as Cursor for the next page. Prefer this over Find Deals Associated With Person, which fetches every deal of one person; use Search Deals to match by title text. Custom fields are keyed by field hash here; use Get Deal for named custom fields. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ ownerId: Property.Number({
+ displayName: 'Owner ID',
+ description: 'Only deals owned by this user ID (from List Users).',
+ required: false,
+ }),
+ personId: Property.Number({
+ displayName: 'Person ID',
+ description: 'Only deals linked to this person ID (from Search Persons).',
+ required: false,
+ }),
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description: 'Only deals linked to this organization ID (from Search Organizations).',
+ required: false,
+ }),
+ pipelineId: Property.Number({
+ displayName: 'Pipeline ID',
+ description: 'Only deals in this pipeline ID (from List Pipelines).',
+ required: false,
+ }),
+ stageId: Property.Number({
+ displayName: 'Stage ID',
+ description: 'Only deals in this stage ID (from List Stages).',
+ required: false,
+ }),
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Only deals with this status. Leave empty for open, won and lost deals.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Open', value: 'open' },
+ { label: 'Won', value: 'won' },
+ { label: 'Lost', value: 'lost' },
+ { label: 'Deleted', value: 'deleted' },
+ ],
+ },
+ }),
+ updatedSince: Property.DateTime({
+ displayName: 'Updated Since',
+ description: 'Only deals updated at or after this time.',
+ required: false,
+ }),
+ updatedUntil: Property.DateTime({
+ displayName: 'Updated Until',
+ description: 'Only deals updated before this time.',
+ required: false,
+ }),
+ sortBy: Property.StaticDropdown({
+ displayName: 'Sort By',
+ description: 'Field to sort by. Defaults to ID.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'ID', value: 'id' },
+ { label: 'Update Time', value: 'update_time' },
+ { label: 'Add Time', value: 'add_time' },
+ ],
+ },
+ }),
+ sortDirection: pipedriveAtomic.sortDirectionProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/deals',
+ resourceLabel: 'deals',
+ query: {
+ owner_id: props.ownerId,
+ person_id: props.personId,
+ org_id: props.organizationId,
+ pipeline_id: props.pipelineId,
+ stage_id: props.stageId,
+ status: props.status,
+ updated_since: pipedriveAtomic.toRfc3339({ value: props.updatedSince, label: 'Updated Since' }),
+ updated_until: pipedriveAtomic.toRfc3339({ value: props.updatedUntil, label: 'Updated Until' }),
+ sort_by: props.sortBy,
+ sort_direction: props.sortDirection,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.toPage(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-organizations.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-organizations.ts
new file mode 100644
index 000000000000..cdc578391f7d
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-organizations.ts
@@ -0,0 +1,71 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2ListResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { listOrganizationsActionOutputSchema } from '../output-schemas';
+
+export const listOrganizationsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-organizations',
+ outputSchema: listOrganizationsActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Organizations',
+ description: 'Lists one page of organizations, optionally filtered.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns one bounded page of organizations filtered by owner or update time; pass next_cursor back as Cursor for the next page. Use Search Organizations to find one by name or address. Custom fields are keyed by field hash here; use Get Organization for named custom fields. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ ownerId: Property.Number({
+ displayName: 'Owner ID',
+ description: 'Only organizations owned by this user ID (from List Users).',
+ required: false,
+ }),
+ updatedSince: Property.DateTime({
+ displayName: 'Updated Since',
+ description: 'Only organizations updated at or after this time.',
+ required: false,
+ }),
+ updatedUntil: Property.DateTime({
+ displayName: 'Updated Until',
+ description: 'Only organizations updated before this time.',
+ required: false,
+ }),
+ sortBy: Property.StaticDropdown({
+ displayName: 'Sort By',
+ description: 'Field to sort by. Defaults to ID.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'ID', value: 'id' },
+ { label: 'Update Time', value: 'update_time' },
+ { label: 'Add Time', value: 'add_time' },
+ ],
+ },
+ }),
+ sortDirection: pipedriveAtomic.sortDirectionProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/organizations',
+ resourceLabel: 'organizations',
+ query: {
+ owner_id: props.ownerId,
+ updated_since: pipedriveAtomic.toRfc3339({ value: props.updatedSince, label: 'Updated Since' }),
+ updated_until: pipedriveAtomic.toRfc3339({ value: props.updatedUntil, label: 'Updated Until' }),
+ sort_by: props.sortBy,
+ sort_direction: props.sortDirection,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.toPage(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-persons.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-persons.ts
new file mode 100644
index 000000000000..65b51a6bfc75
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-persons.ts
@@ -0,0 +1,83 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2ListResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { listPersonsActionOutputSchema } from '../output-schemas';
+
+export const listPersonsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-persons',
+ outputSchema: listPersonsActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Persons',
+ description: 'Lists one page of persons, optionally filtered.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Returns one bounded page of persons (contacts) filtered by owner, organization, deal or update time; pass next_cursor back as Cursor for the next page. Use Search Persons to find someone by name, email or phone. Custom fields are keyed by field hash here; use Get Person for named custom fields. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ ownerId: Property.Number({
+ displayName: 'Owner ID',
+ description: 'Only persons owned by this user ID (from List Users).',
+ required: false,
+ }),
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description: 'Only persons linked to this organization ID (from Search Organizations).',
+ required: false,
+ }),
+ dealId: Property.Number({
+ displayName: 'Deal ID',
+ description: 'Only persons linked to this deal ID (from Search Deals).',
+ required: false,
+ }),
+ updatedSince: Property.DateTime({
+ displayName: 'Updated Since',
+ description: 'Only persons updated at or after this time.',
+ required: false,
+ }),
+ updatedUntil: Property.DateTime({
+ displayName: 'Updated Until',
+ description: 'Only persons updated before this time.',
+ required: false,
+ }),
+ sortBy: Property.StaticDropdown({
+ displayName: 'Sort By',
+ description: 'Field to sort by. Defaults to ID.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'ID', value: 'id' },
+ { label: 'Update Time', value: 'update_time' },
+ { label: 'Add Time', value: 'add_time' },
+ ],
+ },
+ }),
+ sortDirection: pipedriveAtomic.sortDirectionProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/persons',
+ resourceLabel: 'persons',
+ query: {
+ owner_id: props.ownerId,
+ org_id: props.organizationId,
+ deal_id: props.dealId,
+ updated_since: pipedriveAtomic.toRfc3339({ value: props.updatedSince, label: 'Updated Since' }),
+ updated_until: pipedriveAtomic.toRfc3339({ value: props.updatedUntil, label: 'Updated Until' }),
+ sort_by: props.sortBy,
+ sort_direction: props.sortDirection,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.toPage(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-pipelines.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-pipelines.ts
new file mode 100644
index 000000000000..64b04c5f2b2b
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-pipelines.ts
@@ -0,0 +1,53 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2ListResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { listPipelinesActionOutputSchema } from '../output-schemas';
+
+export const listPipelinesAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-pipelines',
+ outputSchema: listPipelinesActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Pipelines',
+ description: 'Lists the sales pipelines.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists the sales pipelines with their IDs and names, one page at a time. Use it to turn a pipeline name into the Pipeline ID that deal filters, Create Deal and Convert Lead to Deal expect; use List Stages for the stages inside a pipeline. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ sortBy: Property.StaticDropdown({
+ displayName: 'Sort By',
+ description: 'Field to sort by. Defaults to ID.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'ID', value: 'id' },
+ { label: 'Update Time', value: 'update_time' },
+ { label: 'Add Time', value: 'add_time' },
+ ],
+ },
+ }),
+ sortDirection: pipedriveAtomic.sortDirectionProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/pipelines',
+ resourceLabel: 'pipelines',
+ query: {
+ sort_by: props.sortBy,
+ sort_direction: props.sortDirection,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.toPage(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-stages.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-stages.ts
new file mode 100644
index 000000000000..4dd5f45844d0
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-stages.ts
@@ -0,0 +1,60 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2ListResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { listStagesActionOutputSchema } from '../output-schemas';
+
+export const listStagesAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-stages',
+ outputSchema: listStagesActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Stages',
+ description: 'Lists pipeline stages.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists deal stages with their IDs, names and pipeline, optionally for one pipeline only, one page at a time. Use it to turn a stage name into the Stage ID that deal filters, Create Deal, Update Deal and Convert Lead to Deal expect. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ pipelineId: Property.Number({
+ displayName: 'Pipeline ID',
+ description: 'Only stages of this pipeline ID (from List Pipelines). Leave empty for all pipelines.',
+ required: false,
+ }),
+ sortBy: Property.StaticDropdown({
+ displayName: 'Sort By',
+ description: 'Field to sort by. Defaults to ID.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'ID', value: 'id' },
+ { label: 'Update Time', value: 'update_time' },
+ { label: 'Add Time', value: 'add_time' },
+ { label: 'Order Number', value: 'order_nr' },
+ ],
+ },
+ }),
+ sortDirection: pipedriveAtomic.sortDirectionProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const response = await pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/stages',
+ resourceLabel: 'stages',
+ query: {
+ pipeline_id: props.pipelineId,
+ sort_by: props.sortBy,
+ sort_direction: props.sortDirection,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.toPage(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/list-users.ts b/packages/pieces/community/pipedrive/src/lib/actions/list-users.ts
new file mode 100644
index 000000000000..e4f6e28078be
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/list-users.ts
@@ -0,0 +1,31 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { listUsersActionOutputSchema } from '../output-schemas';
+
+export const listUsersAction = createAction({
+ auth: pipedriveAuth,
+ name: 'list-users',
+ outputSchema: listUsersActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Users',
+ description: 'Lists the users of the company.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Lists every user in the Pipedrive company with ID, name, email and active state. Use it to turn a teammate\'s name into the Owner ID or User ID other actions expect; Find User instead looks up one user by name or email, and Get Current User returns the connected user. Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {},
+ async run(context) {
+ const response = await pipedriveAtomic.call[] | null>>({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v1/users',
+ resourceLabel: 'users',
+ });
+ const data = response.data ?? [];
+ return { found: data.length > 0, data };
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/merge-deals.ts b/packages/pieces/community/pipedrive/src/lib/actions/merge-deals.ts
new file mode 100644
index 000000000000..716c1b06864d
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/merge-deals.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { mergeDealsActionOutputSchema } from '../output-schemas';
+
+export const mergeDealsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'merge-deals',
+ outputSchema: mergeDealsActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Merge Deals',
+ description: 'Merges one deal into another, removing the first.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Merges one deal into another: the deal given as Deal ID To Merge Away is REMOVED, and its data, notes and activities move to the deal given as Surviving Deal ID, which is the only one that still exists afterwards. Irreversible - confirm both IDs with Get Deal before calling, and note the two deals must use the same currency. Not safe to retry.',
+ idempotent: false,
+ },
+ props: {
+ dealId: Property.Number({
+ displayName: 'Deal ID To Merge Away (this deal is removed)',
+ description:
+ 'The numeric ID of the deal that will be CONSUMED and no longer exist after the merge.',
+ required: true,
+ }),
+ mergeWithId: Property.Number({
+ displayName: 'Surviving Deal ID (this deal is kept)',
+ description:
+ 'The numeric ID of the deal that SURVIVES the merge and receives the other deal\'s data.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { dealId, mergeWithId } = context.propsValue;
+ if (dealId === mergeWithId) {
+ throw new Error('A deal cannot be merged with itself.');
+ }
+ return pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.PUT,
+ resourceUri: `/v1/deals/${dealId}/merge`,
+ resourceLabel: `Deal ${dealId}`,
+ body: { merge_with_id: mergeWithId },
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/merge-organizations.ts b/packages/pieces/community/pipedrive/src/lib/actions/merge-organizations.ts
new file mode 100644
index 000000000000..e8d5146996ed
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/merge-organizations.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { mergeOrganizationsActionOutputSchema } from '../output-schemas';
+
+export const mergeOrganizationsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'merge-organizations',
+ outputSchema: mergeOrganizationsActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Merge Organizations',
+ description: 'Merges one organization into another, removing the first.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Merges one organization into another: the organization given as Organization ID To Merge Away is REMOVED, and its people, deals and activities move to the organization given as Surviving Organization ID, which is the only one that still exists afterwards. Irreversible - confirm both IDs with Get Organization first. Not safe to retry.',
+ idempotent: false,
+ },
+ props: {
+ organizationId: Property.Number({
+ displayName: 'Organization ID To Merge Away (this organization is removed)',
+ description:
+ 'The numeric ID of the organization that will be CONSUMED and no longer exist after the merge.',
+ required: true,
+ }),
+ mergeWithId: Property.Number({
+ displayName: 'Surviving Organization ID (this organization is kept)',
+ description:
+ 'The numeric ID of the organization that SURVIVES the merge and will NOT be overwritten.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { organizationId, mergeWithId } = context.propsValue;
+ if (organizationId === mergeWithId) {
+ throw new Error('An organization cannot be merged with itself.');
+ }
+ return pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.PUT,
+ resourceUri: `/v1/organizations/${organizationId}/merge`,
+ resourceLabel: `Organization ${organizationId}`,
+ body: { merge_with_id: mergeWithId },
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/merge-persons.ts b/packages/pieces/community/pipedrive/src/lib/actions/merge-persons.ts
new file mode 100644
index 000000000000..05e8b7e00bd4
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/merge-persons.ts
@@ -0,0 +1,47 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { mergePersonsActionOutputSchema } from '../output-schemas';
+
+export const mergePersonsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'merge-persons',
+ outputSchema: mergePersonsActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Merge Persons',
+ description: 'Merges one person into another, removing the first.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Merges one person into another: the person given as Person ID To Merge Away is REMOVED, and their deals, notes and activities move to the person given as Surviving Person ID, whose own field values are not overwritten on conflict. Irreversible - confirm both IDs with Get Person first. Not safe to retry.',
+ idempotent: false,
+ },
+ props: {
+ personId: Property.Number({
+ displayName: 'Person ID To Merge Away (this person is removed)',
+ description:
+ 'The numeric ID of the person that will be CONSUMED and no longer exist after the merge.',
+ required: true,
+ }),
+ mergeWithId: Property.Number({
+ displayName: 'Surviving Person ID (this person is kept)',
+ description:
+ 'The numeric ID of the person that SURVIVES the merge and will NOT be overwritten.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { personId, mergeWithId } = context.propsValue;
+ if (personId === mergeWithId) {
+ throw new Error('A person cannot be merged with themselves.');
+ }
+ return pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.PUT,
+ resourceUri: `/v1/persons/${personId}/merge`,
+ resourceLabel: `Person ${personId}`,
+ body: { merge_with_id: mergeWithId },
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/remove-product-from-deal.ts b/packages/pieces/community/pipedrive/src/lib/actions/remove-product-from-deal.ts
new file mode 100644
index 000000000000..51eeeba69b0b
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/remove-product-from-deal.ts
@@ -0,0 +1,42 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { deleteRecordWithAdditionalDataActionOutputSchema } from '../output-schemas';
+
+export const removeProductFromDealAction = createAction({
+ auth: pipedriveAuth,
+ name: 'remove-product-from-deal',
+ outputSchema: deleteRecordWithAdditionalDataActionOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Remove Product From Deal',
+ description: 'Removes a product line from a deal.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Removes one product line from a deal; the product itself stays in the catalog. Needs the row id returned by List Deal Products (the attachment ID), not the product_id. Not safe to retry blindly.',
+ idempotent: false,
+ },
+ props: {
+ dealId: Property.Number({
+ displayName: 'Deal ID',
+ description: 'The numeric ID of the deal the product is attached to.',
+ required: true,
+ }),
+ productAttachmentId: Property.Number({
+ displayName: 'Deal Product ID',
+ description:
+ 'The id of the row returned by List Deal Products (the attachment ID). This is not the product_id.',
+ required: true,
+ }),
+ },
+ async run(context) {
+ const { dealId, productAttachmentId } = context.propsValue;
+ return pipedriveAtomic.call>({
+ auth: context.auth,
+ method: HttpMethod.DELETE,
+ resourceUri: `/v2/deals/${dealId}/products/${productAttachmentId}`,
+ resourceLabel: `Product line ${productAttachmentId} on deal ${dealId}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/search-deals.ts b/packages/pieces/community/pipedrive/src/lib/actions/search-deals.ts
new file mode 100644
index 000000000000..265f08e27ca7
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/search-deals.ts
@@ -0,0 +1,86 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2SearchResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { searchDealsActionOutputSchema } from '../output-schemas';
+
+export const searchDealsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'search-deals',
+ outputSchema: searchDealsActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'Search Deals',
+ description: 'Searches deals by title, notes or custom field text.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Fuzzy text search over deal titles, notes and custom fields, ranked by relevance, optionally narrowed to a person, organization or status; returns one page with next_cursor. Use this to find a deal by name; Find Deal instead does an exact match on one chosen field. The term needs at least 2 characters (1 with Exact Match). Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ term: Property.ShortText({
+ displayName: 'Search Term',
+ description: 'Text to search for. At least 2 characters, or 1 when Exact Match is Yes.',
+ required: true,
+ }),
+ fields: Property.StaticMultiSelectDropdown({
+ displayName: 'Search In',
+ description: 'Fields to search. Leave empty to search all of them.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Title', value: 'title' },
+ { label: 'Notes', value: 'notes' },
+ { label: 'Custom Fields', value: 'custom_fields' },
+ ],
+ },
+ }),
+ exactMatch: pipedriveAtomic.exactMatchProp(),
+ personId: Property.Number({
+ displayName: 'Person ID',
+ description: 'Only deals linked to this person ID (from Search Persons).',
+ required: false,
+ }),
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description: 'Only deals linked to this organization ID (from Search Organizations).',
+ required: false,
+ }),
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Only deals with this status. Leave empty for any status.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Open', value: 'open' },
+ { label: 'Won', value: 'won' },
+ { label: 'Lost', value: 'lost' },
+ ],
+ },
+ }),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const term = pipedriveAtomic.assertSearchTerm({ term: props.term, exactMatch: props.exactMatch });
+ const response = await pipedriveAtomic.call({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/deals/search',
+ resourceLabel: 'deal search',
+ query: {
+ term,
+ fields: props.fields && props.fields.length > 0 ? props.fields : undefined,
+ exact_match: props.exactMatch,
+ person_id: props.personId,
+ organization_id: props.organizationId,
+ status: props.status,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.flattenSearchItems(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/search-organizations.ts b/packages/pieces/community/pipedrive/src/lib/actions/search-organizations.ts
new file mode 100644
index 000000000000..8b7d43443105
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/search-organizations.ts
@@ -0,0 +1,61 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2SearchResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { searchOrganizationsActionOutputSchema } from '../output-schemas';
+
+export const searchOrganizationsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'search-organizations',
+ outputSchema: searchOrganizationsActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'Search Organizations',
+ description: 'Searches organizations by name, address, notes or custom field text.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Fuzzy text search over organization names, addresses, notes and custom fields, ranked by relevance; returns one page with next_cursor. Use this to find a company by name; Find Organization instead does an exact match on one chosen field. The term needs at least 2 characters (1 with Exact Match). Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ term: Property.ShortText({
+ displayName: 'Search Term',
+ description: 'Text to search for. At least 2 characters, or 1 when Exact Match is Yes.',
+ required: true,
+ }),
+ fields: Property.StaticMultiSelectDropdown({
+ displayName: 'Search In',
+ description: 'Fields to search. Leave empty to search all of them.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Name', value: 'name' },
+ { label: 'Address', value: 'address' },
+ { label: 'Notes', value: 'notes' },
+ { label: 'Custom Fields', value: 'custom_fields' },
+ ],
+ },
+ }),
+ exactMatch: pipedriveAtomic.exactMatchProp(),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const term = pipedriveAtomic.assertSearchTerm({ term: props.term, exactMatch: props.exactMatch });
+ const response = await pipedriveAtomic.call({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/organizations/search',
+ resourceLabel: 'organization search',
+ query: {
+ term,
+ fields: props.fields && props.fields.length > 0 ? props.fields : undefined,
+ exact_match: props.exactMatch,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.flattenSearchItems(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/search-persons.ts b/packages/pieces/community/pipedrive/src/lib/actions/search-persons.ts
new file mode 100644
index 000000000000..38e96b3002e9
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/search-persons.ts
@@ -0,0 +1,68 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { V2SearchResponse, pipedriveAtomic } from '../common/atomic-helpers';
+import { searchPersonsActionOutputSchema } from '../output-schemas';
+
+export const searchPersonsAction = createAction({
+ auth: pipedriveAuth,
+ name: 'search-persons',
+ outputSchema: searchPersonsActionOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'Search Persons',
+ description: 'Searches persons by name, email, phone, notes or custom field text.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Fuzzy text search over person names, emails, phones, notes and custom fields, ranked by relevance and optionally limited to one organization; returns one page with next_cursor. Use this to find a contact like "Jane at Acme"; Find Person instead does an exact match on one chosen field. The term needs at least 2 characters (1 with Exact Match). Read-only and safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ term: Property.ShortText({
+ displayName: 'Search Term',
+ description: 'Text to search for. At least 2 characters, or 1 when Exact Match is Yes.',
+ required: true,
+ }),
+ fields: Property.StaticMultiSelectDropdown({
+ displayName: 'Search In',
+ description: 'Fields to search. Leave empty to search all of them.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Name', value: 'name' },
+ { label: 'Email', value: 'email' },
+ { label: 'Phone', value: 'phone' },
+ { label: 'Notes', value: 'notes' },
+ { label: 'Custom Fields', value: 'custom_fields' },
+ ],
+ },
+ }),
+ exactMatch: pipedriveAtomic.exactMatchProp(),
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description: 'Only persons linked to this organization ID (from Search Organizations).',
+ required: false,
+ }),
+ ...pipedriveAtomic.paginationProps(),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const term = pipedriveAtomic.assertSearchTerm({ term: props.term, exactMatch: props.exactMatch });
+ const response = await pipedriveAtomic.call({
+ auth: context.auth,
+ method: HttpMethod.GET,
+ resourceUri: '/v2/persons/search',
+ resourceLabel: 'person search',
+ query: {
+ term,
+ fields: props.fields && props.fields.length > 0 ? props.fields : undefined,
+ exact_match: props.exactMatch,
+ organization_id: props.organizationId,
+ limit: pipedriveAtomic.clampLimit(props.limit),
+ cursor: pipedriveAtomic.emptyToUndefined(props.cursor),
+ },
+ });
+ return pipedriveAtomic.flattenSearchItems(response);
+ },
+});
diff --git a/packages/pieces/community/pipedrive/src/lib/actions/update-note.ts b/packages/pieces/community/pipedrive/src/lib/actions/update-note.ts
new file mode 100644
index 000000000000..e1b114cb8da3
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/actions/update-note.ts
@@ -0,0 +1,101 @@
+import { HttpMethod } from '@activepieces/pieces-common';
+import { createAction, isNil, Property } from '@activepieces/pieces-framework';
+import { pipedriveAuth } from '../auth';
+import { PipedriveEnvelope, pipedriveAtomic } from '../common/atomic-helpers';
+import { updateNoteActionOutputSchema } from '../output-schemas';
+
+export const updateNoteAction = createAction({
+ auth: pipedriveAuth,
+ name: 'update-note',
+ outputSchema: updateNoteActionOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Update Note',
+ description: 'Updates an existing note.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Changes an existing note: its content, the deal, lead, person or organization it is attached to, its author, or its pinned flags. Only the fields you set are sent; at least one is required. To add a new note use Create Note. Setting the same values again gives the same result, so it is safe to retry.',
+ idempotent: true,
+ },
+ props: {
+ noteId: Property.Number({
+ displayName: 'Note ID',
+ description: 'The numeric ID of the note to update (from Find Notes or Create Note).',
+ required: true,
+ }),
+ content: Property.LongText({
+ displayName: 'Content',
+ description: 'New note content. HTML is allowed. Leave empty to keep the current content.',
+ required: false,
+ }),
+ dealId: Property.Number({
+ displayName: 'Deal ID',
+ description: 'Attach the note to this deal ID (from Search Deals).',
+ required: false,
+ }),
+ leadId: Property.ShortText({
+ displayName: 'Lead ID',
+ description: 'Attach the note to this lead UUID (from Find Lead).',
+ required: false,
+ }),
+ personId: Property.Number({
+ displayName: 'Person ID',
+ description: 'Attach the note to this person ID (from Search Persons).',
+ required: false,
+ }),
+ organizationId: Property.Number({
+ displayName: 'Organization ID',
+ description: 'Attach the note to this organization ID (from Search Organizations).',
+ required: false,
+ }),
+ userId: Property.Number({
+ displayName: 'Author User ID',
+ description: 'Make this user ID the note author (from List Users).',
+ required: false,
+ }),
+ pinnedToDeal: pinnedFlagProp('Pinned to Deal'),
+ pinnedToLead: pinnedFlagProp('Pinned to Lead'),
+ pinnedToPerson: pinnedFlagProp('Pinned to Person'),
+ pinnedToOrganization: pinnedFlagProp('Pinned to Organization'),
+ },
+ async run(context) {
+ const props = context.propsValue;
+ const body: Record = {
+ content: pipedriveAtomic.emptyToUndefined(props.content),
+ deal_id: props.dealId,
+ lead_id: pipedriveAtomic.emptyToUndefined(props.leadId),
+ person_id: props.personId,
+ org_id: props.organizationId,
+ user_id: props.userId,
+ pinned_to_deal_flag: props.pinnedToDeal,
+ pinned_to_lead_flag: props.pinnedToLead,
+ pinned_to_person_flag: props.pinnedToPerson,
+ pinned_to_organization_flag: props.pinnedToOrganization,
+ };
+ if (Object.values(body).every((value) => isNil(value))) {
+ throw new Error('Set at least one field to update on the note.');
+ }
+ return pipedriveAtomic.call>>({
+ auth: context.auth,
+ method: HttpMethod.PUT,
+ resourceUri: `/v1/notes/${props.noteId}`,
+ resourceLabel: `Note ${props.noteId}`,
+ body,
+ });
+ },
+});
+
+function pinnedFlagProp(displayName: string) {
+ return Property.StaticDropdown({
+ displayName,
+ description: 'Yes to pin, No to unpin. Leave empty to keep the current setting.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Yes', value: 1 },
+ { label: 'No', value: 0 },
+ ],
+ },
+ });
+}
diff --git a/packages/pieces/community/pipedrive/src/lib/common/atomic-helpers.ts b/packages/pieces/community/pipedrive/src/lib/common/atomic-helpers.ts
new file mode 100644
index 000000000000..f842a36800dd
--- /dev/null
+++ b/packages/pieces/community/pipedrive/src/lib/common/atomic-helpers.ts
@@ -0,0 +1,332 @@
+import { AuthenticationType, httpClient, HttpError, HttpMethod } from '@activepieces/pieces-common';
+import { isNil, Property } from '@activepieces/pieces-framework';
+import { GetField } from './types';
+
+function readVendorMessage(body: unknown): string {
+ if (typeof body === 'string') {
+ return body;
+ }
+ if (typeof body !== 'object' || body === null) {
+ return '';
+ }
+ const parts: string[] = [];
+ if ('error' in body && typeof body.error === 'string') {
+ parts.push(body.error);
+ }
+ if ('error_info' in body && typeof body.error_info === 'string') {
+ parts.push(body.error_info);
+ }
+ if (parts.length === 0 && 'statusText' in body && typeof body.statusText === 'string') {
+ parts.push(body.statusText);
+ }
+ return parts.join(' ');
+}
+
+function describeFailure({
+ status,
+ body,
+ resourceLabel,
+}: {
+ status: number;
+ body: unknown;
+ resourceLabel: string;
+}): string {
+ const vendorMessage = readVendorMessage(body);
+ switch (status) {
+ case 400:
+ return `Pipedrive rejected the request: ${vendorMessage || 'invalid parameters'}.`;
+ case 401:
+ return 'Pipedrive rejected the connection. Reconnect your Pipedrive account and try again.';
+ case 403:
+ return `Pipedrive denied access to ${resourceLabel}: the connected user lacks permission (visibility or role) or the connection lacks the required scope. ${vendorMessage}`.trim();
+ case 404:
+ return `${resourceLabel} was not found (it may be deleted). ${vendorMessage}`.trim();
+ case 429:
+ return 'Pipedrive rate limit reached; retry later.';
+ default:
+ return `Pipedrive returned HTTP ${status} for ${resourceLabel}. ${vendorMessage}`.trim();
+ }
+}
+
+function readApiDomain(auth: PipedriveAtomicAuth): string {
+ const apiDomain = auth.data['api_domain'];
+ if (typeof apiDomain !== 'string' || apiDomain.length === 0) {
+ throw new Error('The Pipedrive connection has no API domain. Reconnect your Pipedrive account.');
+ }
+ return apiDomain;
+}
+
+function toQueryString(query: PipedriveAtomicQuery | undefined): Record {
+ const qs: Record = {};
+ if (isNil(query)) {
+ return qs;
+ }
+ for (const [key, value] of Object.entries(query)) {
+ if (isNil(value)) {
+ continue;
+ }
+ qs[key] = Array.isArray(value) ? value.map(String).join(',') : String(value);
+ }
+ return qs;
+}
+
+function dropNilValues(body: Record | undefined): Record | undefined {
+ if (isNil(body)) {
+ return undefined;
+ }
+ const cleaned: Record = {};
+ for (const [key, value] of Object.entries(body)) {
+ if (!isNil(value)) {
+ cleaned[key] = value;
+ }
+ }
+ return cleaned;
+}
+
+async function call({
+ auth,
+ method,
+ resourceUri,
+ resourceLabel = 'the requested Pipedrive record',
+ query,
+ body,
+}: PipedriveAtomicCallParams): Promise {
+ const apiDomain = readApiDomain(auth);
+ try {
+ const response = await httpClient.sendRequest({
+ method,
+ url: `${apiDomain}/api${resourceUri}`,
+ authentication: {
+ type: AuthenticationType.BEARER_TOKEN,
+ token: auth.access_token,
+ },
+ queryParams: toQueryString(query),
+ body: dropNilValues(body),
+ });
+ return response.body;
+ } catch (error) {
+ if (error instanceof HttpError) {
+ throw new Error(
+ describeFailure({
+ status: error.response.status,
+ body: error.response.body,
+ resourceLabel,
+ }),
+ );
+ }
+ throw error;
+ }
+}
+
+async function fetchCustomFieldDefinitions({
+ auth,
+ resourceUri,
+}: {
+ auth: PipedriveAtomicAuth;
+ resourceUri: string;
+}): Promise {
+ const fields: GetField[] = [];
+ let start: number | undefined = 0;
+ while (!isNil(start)) {
+ const response: V1FieldsPage = await call({
+ auth,
+ method: HttpMethod.GET,
+ resourceUri,
+ resourceLabel: 'custom field definitions',
+ query: { start, limit: 500 },
+ });
+ fields.push(...(response.data ?? []));
+ const pagination = response.additional_data?.pagination;
+ start = pagination?.more_items_in_collection ? pagination.next_start : undefined;
+ }
+ return fields;
+}
+
+function paginationProps() {
+ return {
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Maximum number of items to return in this page (1-500). Defaults to 50.',
+ required: false,
+ defaultValue: DEFAULT_PAGE_LIMIT,
+ }),
+ cursor: Property.ShortText({
+ displayName: 'Cursor',
+ description:
+ 'The next_cursor value from the previous call, to fetch the next page. Leave empty for the first page.',
+ required: false,
+ }),
+ };
+}
+
+function clampLimit(limit: number | undefined): number {
+ if (isNil(limit) || !Number.isFinite(limit)) {
+ return DEFAULT_PAGE_LIMIT;
+ }
+ return Math.min(MAX_PAGE_LIMIT, Math.max(1, Math.floor(limit)));
+}
+
+function emptyToUndefined(value: string | undefined): string | undefined {
+ if (isNil(value)) {
+ return undefined;
+ }
+ const trimmed = value.trim();
+ return trimmed.length === 0 ? undefined : trimmed;
+}
+
+function toRfc3339({ value, label }: { value: string | undefined; label: string }): string | undefined {
+ if (isNil(value) || value.trim().length === 0) {
+ return undefined;
+ }
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) {
+ throw new Error(`${label} must be a valid date and time.`);
+ }
+ return parsed.toISOString().replace(/\.\d{3}Z$/, 'Z');
+}
+
+function assertSearchTerm({
+ term,
+ exactMatch,
+}: {
+ term: string | undefined;
+ exactMatch: boolean | undefined;
+}): string {
+ const trimmed = (term ?? '').trim();
+ const minimum = exactMatch === true ? 1 : 2;
+ if (trimmed.length < minimum) {
+ throw new Error(
+ exactMatch === true
+ ? 'Search Term must contain at least 1 character.'
+ : 'Search Term must contain at least 2 characters (or 1 character when Exact Match is Yes).',
+ );
+ }
+ return trimmed;
+}
+
+function toPage(response: V2ListResponse): AtomicPage {
+ const data = response.data ?? [];
+ return {
+ found: data.length > 0,
+ data,
+ next_cursor: response.additional_data?.next_cursor ?? null,
+ };
+}
+
+function flattenSearchItems(
+ response: V2SearchResponse,
+): AtomicPage> {
+ const items = response.data?.items ?? [];
+ const data = items.map(({ result_score, item }) => ({ ...item, result_score }));
+ return {
+ found: data.length > 0,
+ data,
+ next_cursor: response.additional_data?.next_cursor ?? null,
+ };
+}
+
+function sortDirectionProp() {
+ return Property.StaticDropdown({
+ displayName: 'Sort Direction',
+ description: 'Sort order. Defaults to ascending.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Ascending', value: 'asc' },
+ { label: 'Descending', value: 'desc' },
+ ],
+ },
+ });
+}
+
+function exactMatchProp() {
+ return Property.StaticDropdown({
+ displayName: 'Exact Match',
+ description:
+ 'Yes returns only exact matches of the full term. Leave empty for partial matching.',
+ required: false,
+ options: {
+ disabled: false,
+ options: [
+ { label: 'Yes', value: true },
+ { label: 'No', value: false },
+ ],
+ },
+ });
+}
+
+const DEFAULT_PAGE_LIMIT = 50;
+const MAX_PAGE_LIMIT = 500;
+
+export const pipedriveAtomic = {
+ call,
+ fetchCustomFieldDefinitions,
+ paginationProps,
+ clampLimit,
+ emptyToUndefined,
+ toRfc3339,
+ assertSearchTerm,
+ toPage,
+ flattenSearchItems,
+ sortDirectionProp,
+ exactMatchProp,
+};
+
+type V1FieldsPage = {
+ data: GetField[] | null;
+ additional_data?: {
+ pagination?: {
+ more_items_in_collection?: boolean;
+ next_start?: number;
+ };
+ };
+};
+
+export type PipedriveAtomicAuth = {
+ access_token: string;
+ data: Record;
+};
+
+export type PipedriveAtomicQuery = Record<
+ string,
+ string | number | boolean | string[] | number[] | null | undefined
+>;
+
+export type PipedriveAtomicCallParams = {
+ auth: PipedriveAtomicAuth;
+ method: HttpMethod;
+ resourceUri: string;
+ resourceLabel?: string;
+ query?: PipedriveAtomicQuery;
+ body?: Record;
+};
+
+export type V2ListResponse = {
+ success: boolean;
+ data: T[] | null;
+ additional_data?: {
+ next_cursor?: string | null;
+ };
+};
+
+export type V2SearchResponse = {
+ success: boolean;
+ data: {
+ items?: Array<{ result_score: number; item: Record }>;
+ } | null;
+ additional_data?: {
+ next_cursor?: string | null;
+ };
+};
+
+export type PipedriveEnvelope = {
+ success: boolean;
+ data: T;
+};
+
+export type AtomicPage = {
+ found: boolean;
+ data: T[];
+ next_cursor: string | null;
+};
diff --git a/packages/pieces/community/pipedrive/src/lib/output-schemas.ts b/packages/pieces/community/pipedrive/src/lib/output-schemas.ts
index aaa35ebdf2b6..2da0d84668eb 100644
--- a/packages/pieces/community/pipedrive/src/lib/output-schemas.ts
+++ b/packages/pieces/community/pipedrive/src/lib/output-schemas.ts
@@ -320,6 +320,392 @@ const fileFields: OutputSchema['fields'] = [
{ key: 'update_time', label: 'Updated At', format: 'datetime' },
];
+const idNameFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'ID', format: 'number' },
+ { key: 'name', label: 'Name' },
+];
+
+const orgWithAddressFields: OutputSchema['fields'] = [
+ ...idNameFields,
+ { key: 'address', label: 'Address' },
+];
+
+const searchScoreField: OutputSchema['fields'] = [
+ { key: 'result_score', label: 'Result Score', format: 'number' },
+];
+
+const dealSearchItemFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Deal ID', format: 'number' },
+ { key: 'type', label: 'Type' },
+ { key: 'title', label: 'Title' },
+ { key: 'value', label: 'Value', format: 'number' },
+ { key: 'currency', label: 'Currency' },
+ { key: 'status', label: 'Status' },
+ { key: 'is_archived', label: 'Archived', format: 'boolean' },
+ { key: 'visible_to', label: 'Visible To' },
+ { key: 'owner', label: 'Owner', children: [{ key: 'id', label: 'Owner ID', format: 'number' }] },
+ { key: 'stage', label: 'Stage', children: idNameFields },
+ { key: 'pipeline', label: 'Pipeline', children: [{ key: 'id', label: 'Pipeline ID', format: 'number' }] },
+ { key: 'person', label: 'Person', children: idNameFields },
+ { key: 'organization', label: 'Organization', children: orgWithAddressFields },
+ { key: 'custom_fields', label: 'Custom Fields' },
+ { key: 'notes', label: 'Notes' },
+ ...searchScoreField,
+];
+
+const personSearchItemFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Person ID', format: 'number' },
+ { key: 'type', label: 'Type' },
+ { key: 'name', label: 'Name' },
+ { key: 'phones', label: 'Phones' },
+ { key: 'emails', label: 'Emails' },
+ { key: 'primary_email', label: 'Primary Email', format: 'email' },
+ { key: 'visible_to', label: 'Visible To' },
+ { key: 'owner', label: 'Owner', children: [{ key: 'id', label: 'Owner ID', format: 'number' }] },
+ { key: 'organization', label: 'Organization', children: orgWithAddressFields },
+ { key: 'custom_fields', label: 'Custom Fields' },
+ { key: 'notes', label: 'Notes' },
+ { key: 'update_time', label: 'Updated At', format: 'datetime' },
+ ...searchScoreField,
+];
+
+const organizationSearchItemFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Organization ID', format: 'number' },
+ { key: 'type', label: 'Type' },
+ { key: 'name', label: 'Name' },
+ { key: 'address', label: 'Address' },
+ { key: 'visible_to', label: 'Visible To' },
+ { key: 'owner', label: 'Owner', children: [{ key: 'id', label: 'Owner ID', format: 'number' }] },
+ { key: 'custom_fields', label: 'Custom Fields' },
+ { key: 'notes', label: 'Notes' },
+ ...searchScoreField,
+];
+
+const pipelineFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Pipeline ID', format: 'number' },
+ { key: 'name', label: 'Name' },
+ { key: 'order_nr', label: 'Order', format: 'number' },
+ { key: 'is_deal_probability_enabled', label: 'Deal Probability Enabled', format: 'boolean' },
+ { key: 'is_deleted', label: 'Deleted', format: 'boolean' },
+ { key: 'add_time', label: 'Created At', format: 'datetime' },
+ { key: 'update_time', label: 'Updated At', format: 'datetime' },
+];
+
+const stageFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Stage ID', format: 'number' },
+ { key: 'name', label: 'Name' },
+ { key: 'pipeline_id', label: 'Pipeline ID', format: 'number' },
+ { key: 'order_nr', label: 'Order', format: 'number' },
+ { key: 'deal_probability', label: 'Deal Probability', format: 'number' },
+ { key: 'is_deal_rot_enabled', label: 'Rotting Enabled', format: 'boolean' },
+ { key: 'days_to_rotten', label: 'Days to Rotten', format: 'number' },
+ { key: 'is_deleted', label: 'Deleted', format: 'boolean' },
+ { key: 'add_time', label: 'Created At', format: 'datetime' },
+ { key: 'update_time', label: 'Updated At', format: 'datetime' },
+];
+
+const activityTypeFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Activity Type ID', format: 'number' },
+ { key: 'name', label: 'Name' },
+ { key: 'key_string', label: 'Key' },
+ { key: 'icon_key', label: 'Icon' },
+ { key: 'color', label: 'Color' },
+ { key: 'order_nr', label: 'Order', format: 'number' },
+ { key: 'active_flag', label: 'Active', format: 'boolean' },
+ { key: 'is_custom_flag', label: 'Custom', format: 'boolean' },
+ { key: 'add_time', label: 'Created At' },
+ { key: 'update_time', label: 'Updated At' },
+];
+
+const userDetailFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'User ID', format: 'number' },
+ { key: 'name', label: 'Name' },
+ { key: 'email', label: 'Email', format: 'email' },
+ { key: 'phone', label: 'Phone' },
+ { key: 'active_flag', label: 'Active', format: 'boolean' },
+ { key: 'is_deleted', label: 'Deleted', format: 'boolean' },
+ { key: 'has_created_company', label: 'Has Created Company', format: 'boolean' },
+ {
+ key: 'access',
+ label: 'Access',
+ labelKey: 'app',
+ listItems: [
+ { key: 'app', label: 'App' },
+ { key: 'admin', label: 'Admin', format: 'boolean' },
+ { key: 'permission_set_id', label: 'Permission Set ID' },
+ ],
+ },
+ { key: 'is_admin', label: 'Admin' },
+ { key: 'role_id', label: 'Role ID', format: 'number' },
+ { key: 'default_currency', label: 'Default Currency' },
+ { key: 'locale', label: 'Locale' },
+ { key: 'lang', label: 'Language ID', format: 'number' },
+ { key: 'timezone_name', label: 'Timezone' },
+ { key: 'timezone_offset', label: 'Timezone Offset' },
+ { key: 'icon_url', label: 'Icon URL', format: 'url' },
+ { key: 'is_you', label: 'Is Connected User', format: 'boolean' },
+ { key: 'last_login', label: 'Last Login' },
+ { key: 'created', label: 'Created At' },
+ { key: 'modified', label: 'Updated At' },
+];
+
+const currentUserFields: OutputSchema['fields'] = [
+ ...userDetailFields,
+ { key: 'company_id', label: 'Company ID', format: 'number' },
+ { key: 'company_name', label: 'Company Name' },
+ { key: 'company_domain', label: 'Company Domain' },
+ { key: 'company_country', label: 'Company Country' },
+ {
+ key: 'language',
+ label: 'Language',
+ children: [
+ { key: 'language_code', label: 'Language Code' },
+ { key: 'country_code', label: 'Country Code' },
+ ],
+ },
+];
+
+const leadConversionFields: OutputSchema['fields'] = [
+ { key: 'conversion_id', label: 'Conversion ID' },
+ { key: 'status', label: 'Status' },
+ { key: 'deal_id', label: 'Deal ID', format: 'number' },
+];
+
+const deleteResultFields: OutputSchema['fields'] = [{ key: 'id', label: 'Deleted ID' }];
+
+const customFieldsField: OutputSchema['fields'] = [
+ { key: 'custom_fields', label: 'Custom Fields', dynamicKey: true },
+];
+
+const dealExtraFields: OutputSchema['fields'] = [
+ { key: 'archive_time', label: 'Archived At', format: 'datetime' },
+ { key: 'channel', label: 'Channel' },
+ { key: 'channel_id', label: 'Channel ID' },
+ { key: 'origin', label: 'Origin' },
+ { key: 'origin_id', label: 'Origin ID' },
+ { key: 'local_close_date', label: 'Local Close Date', format: 'date' },
+ { key: 'local_won_date', label: 'Local Won Date', format: 'date' },
+ { key: 'local_lost_date', label: 'Local Lost Date', format: 'date' },
+];
+
+const atomicDealListFields: OutputSchema['fields'] = [
+ ...dealCoreFields,
+ ...dealExtraFields,
+ ...customFieldsField,
+];
+
+const atomicDealDetailFields: OutputSchema['fields'] = [
+ ...dealDetailFields,
+ ...dealExtraFields,
+ { key: 'first_won_time', label: 'First Won At', format: 'datetime' },
+ { key: 'email_messages_count', label: 'Email Messages', format: 'number' },
+ { key: 'last_incoming_mail_time', label: 'Last Incoming Mail', format: 'datetime' },
+ { key: 'last_outgoing_mail_time', label: 'Last Outgoing Mail', format: 'datetime' },
+];
+
+const relatedDealCountFields: OutputSchema['fields'] = [
+ { key: 'related_open_deals_count', label: 'Related Open Deals', format: 'number' },
+ { key: 'related_closed_deals_count', label: 'Related Closed Deals', format: 'number' },
+ { key: 'related_won_deals_count', label: 'Related Won Deals', format: 'number' },
+ { key: 'related_lost_deals_count', label: 'Related Lost Deals', format: 'number' },
+ { key: 'email_messages_count', label: 'Email Messages', format: 'number' },
+];
+
+const atomicPersonListFields: OutputSchema['fields'] = [
+ ...personCoreFields,
+ { key: 'picture_id', label: 'Picture ID', format: 'number' },
+ ...customFieldsField,
+];
+
+const atomicPersonDetailFields: OutputSchema['fields'] = [
+ ...personDetailFields,
+ ...relatedDealCountFields,
+ { key: 'picture_id', label: 'Picture ID', format: 'number' },
+ { key: 'participant_open_deals_count', label: 'Participant Open Deals', format: 'number' },
+ { key: 'participant_closed_deals_count', label: 'Participant Closed Deals', format: 'number' },
+];
+
+const atomicOrganizationListFields: OutputSchema['fields'] = [
+ ...organizationCoreFields,
+ ...customFieldsField,
+];
+
+const mergedRecordFields: OutputSchema['fields'] = [
+ { key: 'merge_what_id', label: 'Merged-Away Record ID', format: 'number' },
+];
+
+const mergedOrganizationFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Surviving Organization ID', format: 'number' },
+];
+
+const atomicOrganizationDetailFields: OutputSchema['fields'] = [
+ ...organizationDetailFields,
+ ...relatedDealCountFields,
+ { key: 'done_activities_count', label: 'Done Activities', format: 'number' },
+ { key: 'undone_activities_count', label: 'Undone Activities', format: 'number' },
+];
+
+const atomicActivityListFields: OutputSchema['fields'] = [
+ ...activityFields,
+ { key: 'private', label: 'Private', format: 'boolean' },
+ { key: 'project_id', label: 'Project ID', format: 'number' },
+ { key: 'conference_meeting_client', label: 'Meeting Client' },
+ { key: 'conference_meeting_id', label: 'Meeting ID' },
+ ...customFieldsField,
+];
+
+const atomicActivityDetailFields: OutputSchema['fields'] = [
+ ...atomicActivityListFields,
+ { key: 'attendees', label: 'Attendees' },
+];
+
+const atomicLeadFields: OutputSchema['fields'] = [
+ ...leadFields,
+ { key: 'archive_time', label: 'Archived At', format: 'datetime' },
+ { key: 'archive_reason', label: 'Archive Reason' },
+ { key: 'channel_id', label: 'Channel ID' },
+ { key: 'origin', label: 'Origin' },
+ { key: 'origin_id', label: 'Origin ID' },
+ { key: 'source_deal_id', label: 'Source Deal ID', format: 'number' },
+];
+
+const atomicNoteFields: OutputSchema['fields'] = [
+ ...noteFields.filter((field) => field.key !== 'user'),
+ {
+ key: 'user',
+ label: 'Author',
+ children: [
+ { key: 'name', label: 'Name' },
+ { key: 'email', label: 'Email', format: 'email' },
+ { key: 'icon_url', label: 'Icon URL', format: 'url' },
+ { key: 'is_you', label: 'Is Connected User', format: 'boolean' },
+ ],
+ },
+ { key: 'lead', label: 'Lead', children: [{ key: 'title', label: 'Title' }] },
+ { key: 'person', label: 'Person', children: [{ key: 'name', label: 'Name' }] },
+ { key: 'organization', label: 'Organization', children: [{ key: 'name', label: 'Name' }] },
+ { key: 'pinned_to_lead_flag', label: 'Pinned to Lead', format: 'boolean' },
+ { key: 'pinned_to_project_flag', label: 'Pinned to Project', format: 'boolean' },
+ { key: 'pinned_to_task_flag', label: 'Pinned to Task', format: 'boolean' },
+ { key: 'project_id', label: 'Project ID', format: 'number' },
+ { key: 'task_id', label: 'Task ID', format: 'number' },
+];
+
+const atomicDealProductFields: OutputSchema['fields'] = [
+ ...dealProductFields,
+ { key: 'billing_frequency', label: 'Billing Frequency' },
+ { key: 'billing_frequency_cycles', label: 'Billing Cycles', format: 'number' },
+ { key: 'billing_start_date', label: 'Billing Start Date', format: 'date' },
+ { key: 'product_variation_id', label: 'Product Variation ID', format: 'number' },
+];
+
+const mergedDealV1Fields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Surviving Deal ID', format: 'number' },
+ { key: 'title', label: 'Title' },
+ { key: 'value', label: 'Value', format: 'number' },
+ { key: 'formatted_value', label: 'Formatted Value' },
+ { key: 'currency', label: 'Currency' },
+ { key: 'status', label: 'Status' },
+ { key: 'pipeline_id', label: 'Pipeline ID', format: 'number' },
+ { key: 'stage_id', label: 'Stage ID', format: 'number' },
+ { key: 'stage_order_nr', label: 'Stage Order', format: 'number' },
+ { key: 'person_id', label: 'Person' },
+ { key: 'person_name', label: 'Person Name' },
+ { key: 'org_id', label: 'Organization' },
+ { key: 'org_name', label: 'Organization Name' },
+ { key: 'user_id', label: 'Owner User ID', format: 'number' },
+ { key: 'owner_name', label: 'Owner Name' },
+ { key: 'creator_user_id', label: 'Creator User ID', format: 'number' },
+ { key: 'probability', label: 'Probability', format: 'number' },
+ { key: 'weighted_value', label: 'Weighted Value', format: 'number' },
+ { key: 'expected_close_date', label: 'Expected Close Date', format: 'date' },
+ { key: 'close_time', label: 'Closed At', format: 'datetime' },
+ { key: 'won_time', label: 'Won At', format: 'datetime' },
+ { key: 'first_won_time', label: 'First Won At', format: 'datetime' },
+ { key: 'lost_time', label: 'Lost At', format: 'datetime' },
+ { key: 'lost_reason', label: 'Lost Reason' },
+ { key: 'label', label: 'Label' },
+ { key: 'active', label: 'Active', format: 'boolean' },
+ { key: 'deleted', label: 'Deleted', format: 'boolean' },
+ { key: 'is_archived', label: 'Archived', format: 'boolean' },
+ { key: 'visible_to', label: 'Visible To' },
+ { key: 'add_time', label: 'Created At', format: 'datetime' },
+ { key: 'update_time', label: 'Updated At', format: 'datetime' },
+ { key: 'stage_change_time', label: 'Stage Changed At', format: 'datetime' },
+ { key: 'next_activity_id', label: 'Next Activity ID', format: 'number' },
+ { key: 'next_activity_date', label: 'Next Activity Date', format: 'date' },
+ { key: 'next_activity_subject', label: 'Next Activity Subject' },
+ { key: 'last_activity_id', label: 'Last Activity ID', format: 'number' },
+ { key: 'last_activity_date', label: 'Last Activity Date', format: 'date' },
+ { key: 'activities_count', label: 'Activities', format: 'number' },
+ { key: 'done_activities_count', label: 'Done Activities', format: 'number' },
+ { key: 'undone_activities_count', label: 'Undone Activities', format: 'number' },
+ { key: 'notes_count', label: 'Notes', format: 'number' },
+ { key: 'files_count', label: 'Files', format: 'number' },
+ { key: 'followers_count', label: 'Followers', format: 'number' },
+ { key: 'participants_count', label: 'Participants', format: 'number' },
+ { key: 'products_count', label: 'Products', format: 'number' },
+ { key: 'email_messages_count', label: 'Email Messages', format: 'number' },
+ { key: 'cc_email', label: 'Smart BCC Email', format: 'email' },
+ { key: 'origin', label: 'Origin' },
+ { key: 'source_lead_id', label: 'Source Lead ID' },
+];
+
+const mergedPersonV1Fields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Surviving Person ID', format: 'number' },
+ { key: 'name', label: 'Name' },
+ { key: 'first_name', label: 'First Name' },
+ { key: 'last_name', label: 'Last Name' },
+ { key: 'primary_email', label: 'Primary Email', format: 'email' },
+ { key: 'email', label: 'Emails', labelKey: 'value', listItems: contactChannelFields },
+ { key: 'phone', label: 'Phones', labelKey: 'value', listItems: contactChannelFields },
+ { key: 'org_id', label: 'Organization' },
+ { key: 'org_name', label: 'Organization Name' },
+ { key: 'owner_id', label: 'Owner ID', format: 'number' },
+ { key: 'owner_name', label: 'Owner Name' },
+ { key: 'job_title', label: 'Job Title' },
+ { key: 'birthday', label: 'Birthday', format: 'date' },
+ { key: 'label', label: 'Label' },
+ { key: 'label_ids', label: 'Label IDs' },
+ { key: 'visible_to', label: 'Visible To' },
+ { key: 'active_flag', label: 'Active', format: 'boolean' },
+ { key: 'add_time', label: 'Created At', format: 'datetime' },
+ { key: 'update_time', label: 'Updated At', format: 'datetime' },
+ { key: 'open_deals_count', label: 'Open Deals', format: 'number' },
+ { key: 'won_deals_count', label: 'Won Deals', format: 'number' },
+ { key: 'lost_deals_count', label: 'Lost Deals', format: 'number' },
+ { key: 'closed_deals_count', label: 'Closed Deals', format: 'number' },
+ { key: 'activities_count', label: 'Activities', format: 'number' },
+ { key: 'done_activities_count', label: 'Done Activities', format: 'number' },
+ { key: 'undone_activities_count', label: 'Undone Activities', format: 'number' },
+ { key: 'notes_count', label: 'Notes', format: 'number' },
+ { key: 'files_count', label: 'Files', format: 'number' },
+ { key: 'followers_count', label: 'Followers', format: 'number' },
+ { key: 'email_messages_count', label: 'Email Messages', format: 'number' },
+ { key: 'last_activity_id', label: 'Last Activity ID', format: 'number' },
+ { key: 'next_activity_id', label: 'Next Activity ID', format: 'number' },
+ { key: 'next_activity_date', label: 'Next Activity Date', format: 'date' },
+ { key: 'last_incoming_mail_time', label: 'Last Incoming Mail', format: 'datetime' },
+ { key: 'last_outgoing_mail_time', label: 'Last Outgoing Mail', format: 'datetime' },
+ { key: 'postal_address_formatted_address', label: 'Postal Address' },
+ { key: 'cc_email', label: 'Smart BCC Email', format: 'email' },
+];
+
+function v1Envelope({
+ label,
+ fields,
+}: {
+ label: string;
+ fields: OutputSchema['fields'];
+}): OutputSchema {
+ return {
+ fields: [
+ ...envelope(label, fields).fields,
+ { key: 'additional_data', label: 'Additional Data' },
+ ],
+ };
+}
+
function envelope(label: string, fields: OutputSchema['fields']): OutputSchema {
return {
fields: [
@@ -346,6 +732,23 @@ function search({
};
}
+function page({
+ label,
+ fields,
+ labelKey,
+}: {
+ label: string;
+ fields: OutputSchema['fields'];
+ labelKey: string;
+}): OutputSchema {
+ return {
+ fields: [
+ ...search({ label, fields, labelKey }).fields,
+ { key: 'next_cursor', label: 'Next Cursor' },
+ ],
+ };
+}
+
export const createPersonActionOutputSchema = envelope('Person', personCoreFields);
export const updatePersonActionOutputSchema = envelope('Person', personCoreFields);
export const addLabelsToPersonActionOutputSchema = envelope('Person', personCoreFields);
@@ -421,6 +824,101 @@ export const findProductsActionOutputSchema = search({
export const addFollowerActionOutputSchema = envelope('Follower', followerFields);
export const attachFileActionOutputSchema = envelope('File', fileFields);
+export const getDealActionOutputSchema = envelope('Deal', atomicDealDetailFields);
+export const getPersonActionOutputSchema = envelope('Person', atomicPersonDetailFields);
+export const getOrganizationActionOutputSchema = envelope('Organization', atomicOrganizationDetailFields);
+export const mergeDealsActionOutputSchema = envelope('Surviving Deal', [
+ ...mergedDealV1Fields,
+ ...mergedRecordFields,
+]);
+export const mergePersonsActionOutputSchema = envelope('Surviving Person', [
+ ...mergedPersonV1Fields,
+ ...mergedRecordFields,
+]);
+export const mergeOrganizationsActionOutputSchema = envelope(
+ 'Surviving Organization',
+ mergedOrganizationFields
+);
+export const getActivityActionOutputSchema = envelope('Activity', atomicActivityDetailFields);
+export const getLeadActionOutputSchema = v1Envelope({ label: 'Lead', fields: atomicLeadFields });
+export const updateNoteActionOutputSchema = envelope('Note', atomicNoteFields);
+export const getCurrentUserActionOutputSchema = v1Envelope({ label: 'User', fields: currentUserFields });
+export const getLeadConversionStatusActionOutputSchema = v1Envelope({ label: 'Conversion', fields: leadConversionFields });
+export const convertLeadToDealActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'conversion_id', label: 'Conversion ID' },
+ { key: 'lead_id', label: 'Lead ID' },
+ ],
+};
+export const deleteRecordActionOutputSchema = envelope('Deleted Record', deleteResultFields);
+export const deleteRecordWithAdditionalDataActionOutputSchema = v1Envelope({ label: 'Deleted Record', fields: deleteResultFields });
+export const deleteNoteActionOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'success', label: 'Success', format: 'boolean' },
+ { key: 'data', label: 'Deleted', format: 'boolean' },
+ ],
+};
+export const listDealsActionOutputSchema = page({
+ label: 'Deals',
+ fields: atomicDealListFields,
+ labelKey: 'title',
+});
+export const searchDealsActionOutputSchema = page({
+ label: 'Deals',
+ fields: dealSearchItemFields,
+ labelKey: 'title',
+});
+export const listDealProductsActionOutputSchema = page({
+ label: 'Deal Products',
+ fields: atomicDealProductFields,
+ labelKey: 'name',
+});
+export const listPersonsActionOutputSchema = page({
+ label: 'People',
+ fields: atomicPersonListFields,
+ labelKey: 'name',
+});
+export const searchPersonsActionOutputSchema = page({
+ label: 'People',
+ fields: personSearchItemFields,
+ labelKey: 'name',
+});
+export const listOrganizationsActionOutputSchema = page({
+ label: 'Organizations',
+ fields: atomicOrganizationListFields,
+ labelKey: 'name',
+});
+export const searchOrganizationsActionOutputSchema = page({
+ label: 'Organizations',
+ fields: organizationSearchItemFields,
+ labelKey: 'name',
+});
+export const listActivitiesActionOutputSchema = page({
+ label: 'Activities',
+ fields: atomicActivityListFields,
+ labelKey: 'subject',
+});
+export const listPipelinesActionOutputSchema = page({
+ label: 'Pipelines',
+ fields: pipelineFields,
+ labelKey: 'name',
+});
+export const listStagesActionOutputSchema = page({
+ label: 'Stages',
+ fields: stageFields,
+ labelKey: 'name',
+});
+export const listActivityTypesActionOutputSchema = search({
+ label: 'Activity Types',
+ fields: activityTypeFields,
+ labelKey: 'name',
+});
+export const listUsersActionOutputSchema = search({
+ label: 'Users',
+ fields: userDetailFields,
+ labelKey: 'name',
+});
+
export const newPersonTriggerOutputSchema: OutputSchema = { fields: personDetailFields };
export const updatedPersonTriggerOutputSchema: OutputSchema = { fields: personDetailFields };
export const newDealTriggerOutputSchema: OutputSchema = { fields: dealDetailFields };
diff --git a/packages/pieces/community/whatsapp/package.json b/packages/pieces/community/whatsapp/package.json
index df9c286165aa..6559f71da610 100644
--- a/packages/pieces/community/whatsapp/package.json
+++ b/packages/pieces/community/whatsapp/package.json
@@ -1,12 +1,13 @@
{
"name": "@activepieces/piece-whatsapp",
- "version": "0.3.0",
+ "version": "1.0.0",
"main": "./dist/src/index.js",
"types": "./dist/src/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.lib.json && cp package.json dist/",
"bundle": "node ../../../../dist/packages/cli/src/index.js pieces bundle",
- "lint": "eslint 'src/**/*.ts'"
+ "lint": "eslint 'src/**/*.ts'",
+ "test": "vitest run"
},
"dependencies": {
"@activepieces/pieces-common": "workspace:*",
@@ -15,6 +16,7 @@
"@activepieces/core-utils": "workspace:*"
},
"devDependencies": {
- "tslib": "2.6.2"
+ "tslib": "2.6.2",
+ "vitest": "3.2.6"
}
}
diff --git a/packages/pieces/community/whatsapp/src/i18n/translation.json b/packages/pieces/community/whatsapp/src/i18n/translation.json
index babe3debfd82..9ff2439ee32d 100644
--- a/packages/pieces/community/whatsapp/src/i18n/translation.json
+++ b/packages/pieces/community/whatsapp/src/i18n/translation.json
@@ -1,31 +1,227 @@
{
- "Manage your WhatsApp business account": "Manage your WhatsApp business account",
+ "Send text, media and template messages from your WhatsApp Business number.": "Send text, media and template messages from your WhatsApp Business number.",
"System User Access Token": "System User Access Token",
"Business Account ID": "Business Account ID",
- "The system user access token of your WhatsApp business account.": "The system user access token of your WhatsApp business account.",
- "The business account ID of your WhatsApp business account.": "The business account ID of your WhatsApp business account.",
- "\nTo Obtain a Phone Number ID and a Permanent System User Access Token, follow these steps:\n\n1. Go to https://developers.facebook.com/\n2. Make a new app, Select Other for usecase.\n3. Choose Business as the type of app.\n4. Add new Product -> WhatsApp.\n5. Navigate to WhatsApp Settings > API Setup.\n6. Copy the Business Account ID.\n7. Login to your [Meta Business Manager](https://business.facebook.com/).\n8. Click on Settings.\n9. Create a new System User with access over the app and copy the access token.\n": "\nTo Obtain a Phone Number ID and a Permanent System User Access Token, follow these steps:\n\n1. Go to https://developers.facebook.com/\n2. Make a new app, Select Other for usecase.\n3. Choose Business as the type of app.\n4. Add new Product -> WhatsApp.\n5. Navigate to WhatsApp Settings > API Setup.\n6. Copy the Business Account ID.\n7. Login to your [Meta Business Manager](https://business.facebook.com/).\n8. Click on Settings.\n9. Create a new System User with access over the app and copy the access token.\n",
+ "App Secret": "App Secret",
+ "Permanent token of a System User with access to the app.": "Permanent token of a System User with access to the app.",
+ "Found under WhatsApp > API Setup in your Meta app.": "Found under WhatsApp > API Setup in your Meta app.",
+ "Optional. Your Meta app secret (App settings > Basic). When set, every webhook delivery must carry a valid X-Hub-Signature-256 or it is ignored.": "Optional. Your Meta app secret (App settings > Basic). When set, every webhook delivery must carry a valid X-Hub-Signature-256 or it is ignored.",
+ "\nYou need the WhatsApp Business Account ID and a permanent System User access token from Meta.\n\n1. Open [Meta for Developers](https://developers.facebook.com/) and create a **Business** app (use case **Other**).\n2. Add the **WhatsApp** product to the app.\n3. Go to **WhatsApp > API Setup** and copy the **WhatsApp Business Account ID**.\n4. In [Meta Business Manager](https://business.facebook.com/) open **Settings > System Users**, add a system user with access to the app, and generate a permanent token with t": "\nYou need the WhatsApp Business Account ID and a permanent System User access token from Meta.\n\n1. Open [Meta for Developers](https://developers.facebook.com/) and create a **Business** app (use case **Other**).\n2. Add the **WhatsApp** product to the app.\n3. Go to **WhatsApp > API Setup** and copy the **WhatsApp Business Account ID**.\n4. In [Meta Business Manager](https://business.facebook.com/) open **Settings > System Users**, add a system user with access to the app, and generate a permanent token with the **whatsapp_business_messaging** and **whatsapp_business_management** permissions.\n\nOptional: to have Activepieces verify that webhook deliveries really come from Meta, also paste your **App Secret** (App settings > Basic > App Secret). Triggers then reject any request that is not signed with it.\n",
"Send Message": "Send Message",
"Send Media": "Send Media",
"Send Template Message": "Send Template Message",
+ "Send Template": "Send Template",
+ "Send Interactive Buttons": "Send Interactive Buttons",
+ "Send Interactive List": "Send Interactive List",
+ "Send Interactive CTA URL": "Send Interactive CTA URL",
+ "Send Location": "Send Location",
+ "Send Contact": "Send Contact",
+ "Send Reaction": "Send Reaction",
+ "Mark Message As Read": "Mark Message As Read",
+ "Upload Media": "Upload Media",
+ "Get Media URL": "Get Media URL",
+ "Download Media": "Download Media",
+ "Delete Media": "Delete Media",
+ "List Message Templates": "List Message Templates",
+ "Create Message Template": "Create Message Template",
+ "Edit Message Template": "Edit Message Template",
+ "Delete Message Template": "Delete Message Template",
+ "List Phone Numbers": "List Phone Numbers",
+ "Get Phone Number": "Get Phone Number",
+ "Get Business Profile": "Get Business Profile",
+ "Update Business Profile": "Update Business Profile",
"Send a text message through WhatsApp": "Send a text message through WhatsApp",
- "Send a media message through WhatsApp": "Send a media message through WhatsApp",
- "Sends a template message.": "Sends a template message.",
- "Phone Number ID": "Phone Number ID",
+ "Send an image, video, audio, document or sticker by URL.": "Send an image, video, audio, document or sticker by URL.",
+ "Send an approved template, filling in its placeholders.": "Send an approved template, filling in its placeholders.",
+ "Sends an approved template by name with positional parameters.": "Sends an approved template by name with positional parameters.",
+ "Sends a message with up to three quick-reply buttons.": "Sends a message with up to three quick-reply buttons.",
+ "Sends a message that opens a menu of up to ten selectable rows.": "Sends a message that opens a menu of up to ten selectable rows.",
+ "Sends a message with a single button that opens a web link.": "Sends a message with a single button that opens a web link.",
+ "Sends a map pin for a latitude and longitude.": "Sends a map pin for a latitude and longitude.",
+ "Sends a contact card the recipient can save.": "Sends a contact card the recipient can save.",
+ "Reacts to a message with an emoji, or removes an existing reaction.": "Reacts to a message with an emoji, or removes an existing reaction.",
+ "Shows the sender blue ticks for a received message, optionally with a typing indicator.": "Shows the sender blue ticks for a received message, optionally with a typing indicator.",
+ "Uploads a file to WhatsApp and returns a media ID you can send.": "Uploads a file to WhatsApp and returns a media ID you can send.",
+ "Looks up a media ID and returns its download URL and metadata.": "Looks up a media ID and returns its download URL and metadata.",
+ "Downloads a received media file so later steps can use it.": "Downloads a received media file so later steps can use it.",
+ "Deletes an uploaded media file from WhatsApp.": "Deletes an uploaded media file from WhatsApp.",
+ "Lists the message templates on the business account, optionally filtered by status.": "Lists the message templates on the business account, optionally filtered by status.",
+ "Submits a new message template for Meta review.": "Submits a new message template for Meta review.",
+ "Replaces the components of an existing template and resubmits it for review.": "Replaces the components of an existing template and resubmits it for review.",
+ "Deletes a message template from the business account.": "Deletes a message template from the business account.",
+ "Lists the phone numbers registered on the business account.": "Lists the phone numbers registered on the business account.",
+ "Returns the details and health of one business phone number.": "Returns the details and health of one business phone number.",
+ "Returns the public business profile shown for a phone number.": "Returns the public business profile shown for a phone number.",
+ "Updates the public business profile for a phone number. Empty fields are left unchanged.": "Updates the public business profile for a phone number. Empty fields are left unchanged.",
+ "From Phone Number": "From Phone Number",
"To": "To",
"Message": "Message",
- "Type": "Type",
+ "Media Type": "Media Type",
"Media URL": "Media URL",
"Caption": "Caption",
"Filename": "Filename",
- "Message Template ID": "Message Template ID",
+ "Template": "Template",
"Template Fields": "Template Fields",
- "Phone number ID that will be used to send the message.": "Phone number ID that will be used to send the message.",
- "The recipient of the message": "The recipient of the message",
- "The message to send": "The message to send",
- "The type of media to send": "The type of media to send",
- "The URL of the media to send": "The URL of the media to send",
- "A caption for the media": "A caption for the media",
- "Filename of the document to send": "Filename of the document to send",
- "Recipient phone number.": "Recipient phone number."
+ "Template Name": "Template Name",
+ "Language Code": "Language Code",
+ "Body Parameters": "Body Parameters",
+ "Header Parameters": "Header Parameters",
+ "Button URL Parameters": "Button URL Parameters",
+ "Reply To Message ID": "Reply To Message ID",
+ "Body": "Body",
+ "Buttons": "Buttons",
+ "Header Type": "Header Type",
+ "Header Text": "Header Text",
+ "Header Media URL": "Header Media URL",
+ "Footer": "Footer",
+ "Button Text": "Button Text",
+ "Section Title": "Section Title",
+ "Rows": "Rows",
+ "Button URL": "Button URL",
+ "Latitude": "Latitude",
+ "Longitude": "Longitude",
+ "Location Name": "Location Name",
+ "Address": "Address",
+ "Full Name": "Full Name",
+ "First Name": "First Name",
+ "Last Name": "Last Name",
+ "Phone Numbers": "Phone Numbers",
+ "Emails": "Emails",
+ "Company": "Company",
+ "Job Title": "Job Title",
+ "Website": "Website",
+ "Message ID": "Message ID",
+ "Emoji": "Emoji",
+ "Show Typing Indicator": "Show Typing Indicator",
+ "File": "File",
+ "File Type": "File Type",
+ "Media ID": "Media ID",
+ "Status": "Status",
+ "Limit": "Limit",
+ "Name": "Name",
+ "Language": "Language",
+ "Category": "Category",
+ "Components": "Components",
+ "About": "About",
+ "Description": "Description",
+ "Email": "Email",
+ "Websites": "Websites",
+ "Vertical": "Vertical",
+ "The business number the message is sent from.": "The business number the message is sent from.",
+ "Recipient's phone number in international format.": "Recipient's phone number in international format.",
+ "Plain text, up to 4096 characters.": "Plain text, up to 4096 characters.",
+ "What kind of file the URL points to.": "What kind of file the URL points to.",
+ "Public link WhatsApp downloads when it sends the message.": "Public link WhatsApp downloads when it sends the message.",
+ "Text shown under the media. Ignored for audio and stickers.": "Text shown under the media. Ignored for audio and stickers.",
+ "Documents only: the file name the recipient sees.": "Documents only: the file name the recipient sees.",
+ "Only templates approved by WhatsApp are delivered.": "Only templates approved by WhatsApp are delivered.",
+ "Fill in each placeholder of the selected template.": "Fill in each placeholder of the selected template.",
+ "Recipient phone number in international format, digits only (for example 962791234567).": "Recipient phone number in international format, digits only (for example 962791234567).",
+ "Exact template name, for example hello_world. Get it from List Message Templates.": "Exact template name, for example hello_world. Get it from List Message Templates.",
+ "Language and locale of the approved template, for example en_US.": "Language and locale of the approved template, for example en_US.",
+ "Values for the body placeholders, in order: the first entry fills {{1}}, the second {{2}}.": "Values for the body placeholders, in order: the first entry fills {{1}}, the second {{2}}.",
+ "Values for text header placeholders, in order.": "Values for text header placeholders, in order.",
+ "Values for the dynamic URL suffix of the first URL button, in order.": "Values for the dynamic URL suffix of the first URL button, in order.",
+ "Optional WhatsApp message ID (wamid) to send this message as a reply to.": "Optional WhatsApp message ID (wamid) to send this message as a reply to.",
+ "Main message text.": "Main message text.",
+ "Between one and three buttons. Titles must be unique and at most 20 characters.": "Between one and three buttons. Titles must be unique and at most 20 characters.",
+ "Optional header shown above the body: a short text line or a media item from a public URL.": "Optional header shown above the body: a short text line or a media item from a public URL.",
+ "Used when Header Type is Text. Max 60 characters.": "Used when Header Type is Text. Max 60 characters.",
+ "Public URL of the header image, video or document. Used when Header Type is a media type.": "Public URL of the header image, video or document. Used when Header Type is a media type.",
+ "Optional footer text, max 60 characters.": "Optional footer text, max 60 characters.",
+ "Label of the button that opens the list. Max 20 characters.": "Label of the button that opens the list. Max 20 characters.",
+ "Title shown above the rows. Max 24 characters.": "Title shown above the rows. Max 24 characters.",
+ "Between one and ten rows. Titles max 24 characters, descriptions max 72.": "Between one and ten rows. Titles max 24 characters, descriptions max 72.",
+ "Label of the button. Max 20 characters.": "Label of the button. Max 20 characters.",
+ "The link the button opens.": "The link the button opens.",
+ "Decimal degrees, for example 31.9539.": "Decimal degrees, for example 31.9539.",
+ "Decimal degrees, for example 35.9106.": "Decimal degrees, for example 35.9106.",
+ "Title shown under the map pin, for example a venue or shop name.": "Title shown under the map pin, for example a venue or shop name.",
+ "Street address shown under the name. Only displayed when a name is also set.": "Street address shown under the name. Only displayed when a name is also set.",
+ "Display name shown on the card.": "Display name shown on the card.",
+ "One entry per number the recipient can call or message.": "One entry per number the recipient can call or message.",
+ "One entry per email address.": "One entry per email address.",
+ "The WhatsApp message ID (wamid), for example from the New Incoming Message trigger.": "The WhatsApp message ID (wamid), for example from the New Incoming Message trigger.",
+ "A single emoji such as 👍. Leave empty to remove your reaction.": "A single emoji such as 👍. Leave empty to remove your reaction.",
+ "Display \"typing…\" to the sender until you reply or 25 seconds pass.": "Display \"typing…\" to the sender until you reply or 25 seconds pass.",
+ "The file to upload, from a previous step or a URL.": "The file to upload, from a previous step or a URL.",
+ "Must match the actual file contents; WhatsApp rejects mismatches.": "Must match the actual file contents; WhatsApp rejects mismatches.",
+ "The media ID returned by Upload Media or received on an incoming media message.": "The media ID returned by Upload Media or received on an incoming media message.",
+ "Only return templates in this review state. Leave empty for all.": "Only return templates in this review state. Leave empty for all.",
+ "Maximum number of templates to return. Defaults to 100.": "Maximum number of templates to return. Defaults to 100.",
+ "Lowercase letters, digits and underscores only, for example order_update.": "Lowercase letters, digits and underscores only, for example order_update.",
+ "Language and locale code, for example en_US.": "Language and locale code, for example en_US.",
+ "Meta reviews and prices the template by category. Utility for transactional updates, Marketing for promotions, Authentication for one-time passcodes.": "Meta reviews and prices the template by category. Utility for transactional updates, Marketing for promotions, Authentication for one-time passcodes.",
+ "Template components array as documented by Meta. Write variables as [[1]] or [[name]] instead of double curly braces, which Activepieces reserves for its own expressions; they are converted before sending. Example: [{\"type\":\"BODY\",\"text\":\"Hello [[1]]\",\"example\":{\"body_text\":[[\"Sam\"]]}}].": "Template components array as documented by Meta. Write variables as [[1]] or [[name]] instead of double curly braces, which Activepieces reserves for its own expressions; they are converted before sending. Example: [{\"type\":\"BODY\",\"text\":\"Hello [[1]]\",\"example\":{\"body_text\":[[\"Sam\"]]}}].",
+ "Leave empty to keep the current category.": "Leave empty to keep the current category.",
+ "Short status line, max 139 characters.": "Short status line, max 139 characters.",
+ "Up to two URLs.": "Up to two URLs.",
+ "Industry shown on the business profile.": "Industry shown on the business profile.",
+ "None": "None",
+ "Text": "Text",
+ "Image": "Image",
+ "Video": "Video",
+ "Document": "Document",
+ "JPEG image": "JPEG image",
+ "PNG image": "PNG image",
+ "WebP sticker": "WebP sticker",
+ "MP4 video": "MP4 video",
+ "3GPP video": "3GPP video",
+ "AAC audio": "AAC audio",
+ "AMR audio": "AMR audio",
+ "MP3 audio": "MP3 audio",
+ "MP4 audio": "MP4 audio",
+ "OGG audio": "OGG audio",
+ "PDF document": "PDF document",
+ "Plain text": "Plain text",
+ "Word document": "Word document",
+ "Excel spreadsheet": "Excel spreadsheet",
+ "PowerPoint presentation": "PowerPoint presentation",
+ "Approved": "Approved",
+ "Pending": "Pending",
+ "Rejected": "Rejected",
+ "Paused": "Paused",
+ "Disabled": "Disabled",
+ "Marketing": "Marketing",
+ "Utility": "Utility",
+ "Authentication": "Authentication",
+ "OTHER": "OTHER",
+ "AUTO": "AUTO",
+ "BEAUTY": "BEAUTY",
+ "APPAREL": "APPAREL",
+ "EDU": "EDU",
+ "ENTERTAIN": "ENTERTAIN",
+ "EVENT_PLAN": "EVENT_PLAN",
+ "FINANCE": "FINANCE",
+ "GROCERY": "GROCERY",
+ "GOVT": "GOVT",
+ "HOTEL": "HOTEL",
+ "HEALTH": "HEALTH",
+ "NONPROFIT": "NONPROFIT",
+ "PROF_SERVICES": "PROF_SERVICES",
+ "RETAIL": "RETAIL",
+ "TRAVEL": "TRAVEL",
+ "RESTAURANT": "RESTAURANT",
+ "ALCOHOL": "ALCOHOL",
+ "ONLINE_GAMBLING": "ONLINE_GAMBLING",
+ "PHYSICAL_GAMBLING": "PHYSICAL_GAMBLING",
+ "OTC_DRUGS": "OTC_DRUGS",
+ "New Incoming Message": "New Incoming Message",
+ "New Message Reaction": "New Message Reaction",
+ "Message Status Updated": "Message Status Updated",
+ "Template Status Updated": "Template Status Updated",
+ "Fires when a customer sends your business number a message.": "Fires when a customer sends your business number a message.",
+ "Fires when a customer reacts to one of your messages with an emoji, or removes a reaction.": "Fires when a customer reacts to one of your messages with an emoji, or removes a reaction.",
+ "Fires when a message you sent is delivered, read, or fails.": "Fires when a message you sent is delivered, read, or fails.",
+ "Fires when Meta approves, rejects, pauses or otherwise changes a message template.": "Fires when Meta approves, rejects, pauses or otherwise changes a message template.",
+ "Markdown": "Markdown",
+ "Verify Token": "Verify Token",
+ "Statuses": "Statuses",
+ "\n**Connect this trigger to your Meta app**\n\n1. **Publish this flow first.** Meta verifies the URL immediately, and an unpublished flow cannot answer.\n2. Open [developers.facebook.com](https://developers.facebook.com/apps), pick your app and go to **WhatsApp → Configuration**.\n3. Under **Webhook**, click **Edit** and paste this URL as the Callback URL:\n\n `{{webhookUrl}}`\n\n4. Enter the same value you put in **Verify Token** below (any secret string), then click **Verify and save**.\n5. Click **Manage** next ": "\n**Connect this trigger to your Meta app**\n\n1. **Publish this flow first.** Meta verifies the URL immediately, and an unpublished flow cannot answer.\n2. Open [developers.facebook.com](https://developers.facebook.com/apps), pick your app and go to **WhatsApp → Configuration**.\n3. Under **Webhook**, click **Edit** and paste this URL as the Callback URL:\n\n `{{webhookUrl}}`\n\n4. Enter the same value you put in **Verify Token** below (any secret string), then click **Verify and save**.\n5. Click **Manage** next to Webhook fields and subscribe to **messages** (and **message_template_status_update** for template triggers).\n6. Make sure your app is subscribed to the WhatsApp Business Account itself. If events never arrive although the URL verified, call `POST /{WABA_ID}/subscribed_apps` with your access token; the dashboard does not show this state.\n\nTo reject deliveries that are not signed by Meta, add your **App Secret** to the WhatsApp connection.\n\nMeta accepts **one callback URL per app**, so only the flow whose URL you paste receives events. To react to several event kinds, use one flow with this trigger and branch on the payload, or forward the payload to your other flows from a step.\n",
+ "Optional. If set, Meta must send this exact token during webhook verification or the handshake is rejected.": "Optional. If set, Meta must send this exact token during webhook verification or the handshake is rejected.",
+ "Only fire for these statuses. Leave empty for all.": "Only fire for these statuses. Leave empty for all.",
+ "Sent": "Sent",
+ "Delivered": "Delivered",
+ "Read": "Read",
+ "Failed": "Failed"
}
\ No newline at end of file
diff --git a/packages/pieces/community/whatsapp/src/index.ts b/packages/pieces/community/whatsapp/src/index.ts
index 9cfa156a59ac..27aa4c8cdc15 100644
--- a/packages/pieces/community/whatsapp/src/index.ts
+++ b/packages/pieces/community/whatsapp/src/index.ts
@@ -2,6 +2,30 @@ import { createPiece, PieceCategory } from '@activepieces/pieces-framework';
import { sendMessage } from './lib/actions/send-message';
import { sendMedia } from './lib/actions/send-media';
import { sendTemplateMessageAction } from './lib/actions/send-from-template';
+import { sendTemplate } from './lib/actions/send-template';
+import { sendInteractiveButtons } from './lib/actions/send-interactive-buttons';
+import { sendInteractiveList } from './lib/actions/send-interactive-list';
+import { sendInteractiveCtaUrl } from './lib/actions/send-interactive-cta-url';
+import { sendLocation } from './lib/actions/send-location';
+import { sendContact } from './lib/actions/send-contact';
+import { sendReaction } from './lib/actions/send-reaction';
+import { markMessageAsRead } from './lib/actions/mark-message-as-read';
+import { uploadMedia } from './lib/actions/upload-media';
+import { getMediaUrl } from './lib/actions/get-media-url';
+import { downloadMedia } from './lib/actions/download-media';
+import { deleteMedia } from './lib/actions/delete-media';
+import { listMessageTemplates } from './lib/actions/list-message-templates';
+import { createMessageTemplate } from './lib/actions/create-message-template';
+import { editMessageTemplate } from './lib/actions/edit-message-template';
+import { deleteMessageTemplate } from './lib/actions/delete-message-template';
+import { listPhoneNumbers } from './lib/actions/list-phone-numbers';
+import { getPhoneNumber } from './lib/actions/get-phone-number';
+import { getBusinessProfile } from './lib/actions/get-business-profile';
+import { updateBusinessProfile } from './lib/actions/update-business-profile';
+import { newIncomingMessage } from './lib/triggers/new-incoming-message';
+import { newMessageReaction } from './lib/triggers/new-message-reaction';
+import { messageStatusUpdated } from './lib/triggers/message-status-updated';
+import { templateStatusUpdated } from './lib/triggers/template-status-updated';
import { whatsappAuth } from './lib/auth';
export const whatsapp = createPiece({
@@ -12,6 +36,30 @@ export const whatsapp = createPiece({
logoUrl: 'https://cdn.activepieces.com/pieces/whatsapp.png',
categories: [PieceCategory.COMMUNICATION],
authors: ['LevwTech', 'kishanprmr'],
- actions: [sendMessage, sendMedia, sendTemplateMessageAction],
- triggers: [],
+ actions: [
+ sendMessage,
+ sendMedia,
+ sendTemplateMessageAction,
+ sendTemplate,
+ sendInteractiveButtons,
+ sendInteractiveList,
+ sendInteractiveCtaUrl,
+ sendLocation,
+ sendContact,
+ sendReaction,
+ markMessageAsRead,
+ uploadMedia,
+ getMediaUrl,
+ downloadMedia,
+ deleteMedia,
+ listMessageTemplates,
+ createMessageTemplate,
+ editMessageTemplate,
+ deleteMessageTemplate,
+ listPhoneNumbers,
+ getPhoneNumber,
+ getBusinessProfile,
+ updateBusinessProfile,
+ ],
+ triggers: [newIncomingMessage, newMessageReaction, messageStatusUpdated, templateStatusUpdated],
});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/create-message-template.ts b/packages/pieces/community/whatsapp/src/lib/actions/create-message-template.ts
new file mode 100644
index 000000000000..a887310d315b
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/create-message-template.ts
@@ -0,0 +1,44 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { createMessageTemplateOutputSchema } from '../output-schemas';
+
+export const createMessageTemplate = createAction({
+ auth: whatsappAuth,
+ name: 'create_message_template',
+ outputSchema: createMessageTemplateOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Create Message Template',
+ description: 'Submits a new message template for Meta review.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Creates a WhatsApp message template on the business account and submits it for Meta approval; it returns the new template id and a PENDING status, and Template Status Updated fires when review completes. Names must be lowercase letters, digits and underscores, unique per language. Not idempotent — a duplicate name for the same language is rejected rather than reused.',
+ idempotent: false,
+ },
+ props: {
+ name: Property.ShortText({
+ displayName: 'Name',
+ description: 'Lowercase letters, digits and underscores only, for example order_update.',
+ required: true,
+ }),
+ language: Property.ShortText({
+ displayName: 'Language',
+ description: 'Language and locale code, for example en_US.',
+ required: true,
+ }),
+ category: whatsappProps.templateCategory,
+ components: whatsappProps.templateComponents,
+ },
+ async run(context) {
+ const { name, language, category, components } = context.propsValue;
+ return whatsappClient.request<{ id: string; status: string; category: string }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.POST,
+ path: `/${context.auth.props.businessAccountId}/message_templates`,
+ body: { name, language, category, components: whatsappClient.normalizeTemplateComponents(components) },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/delete-media.ts b/packages/pieces/community/whatsapp/src/lib/actions/delete-media.ts
new file mode 100644
index 000000000000..69feb74f06a8
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/delete-media.ts
@@ -0,0 +1,31 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { deleteMediaOutputSchema } from '../output-schemas';
+
+export const deleteMedia = createAction({
+ auth: whatsappAuth,
+ name: 'delete_media',
+ outputSchema: deleteMediaOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Media',
+ description: 'Deletes an uploaded media file from WhatsApp.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Permanently deletes a media file previously stored on WhatsApp by its media ID, so it can no longer be sent or downloaded. Use it to clean up after Upload Media; messages already delivered are unaffected. Not idempotent — a second call for the same ID fails because the media no longer exists.',
+ idempotent: false,
+ },
+ props: {
+ media_id: whatsappProps.mediaId,
+ },
+ async run(context) {
+ return whatsappClient.request<{ success: boolean }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.DELETE,
+ path: `/${context.propsValue.media_id}`,
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/delete-message-template.ts b/packages/pieces/community/whatsapp/src/lib/actions/delete-message-template.ts
new file mode 100644
index 000000000000..d40df4c3cc6d
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/delete-message-template.ts
@@ -0,0 +1,40 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappClient } from '../common/client';
+import { deleteMessageTemplateOutputSchema } from '../output-schemas';
+
+export const deleteMessageTemplate = createAction({
+ auth: whatsappAuth,
+ name: 'delete_message_template',
+ outputSchema: deleteMessageTemplateOutputSchema,
+ classification: 'DESTRUCTIVE',
+ displayName: 'Delete Message Template',
+ description: 'Deletes a message template from the business account.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Deletes one WhatsApp message template, selected by id, from the business account. Deleting an approved template blocks reuse of its name for 30 days, and templates with messages still in flight move to PENDING_DELETION first. Not idempotent — a second call for the same template fails because it no longer exists.',
+ idempotent: false,
+ },
+ props: {
+ message_template_id: commonProps.message_template_id,
+ },
+ async run(context) {
+ const accessToken = context.auth.props.access_token;
+ const templateId = context.propsValue.message_template_id;
+ const template = await whatsappClient.request<{ id: string; name: string }>({
+ accessToken,
+ method: HttpMethod.GET,
+ path: `/${templateId}`,
+ queryParams: { fields: 'id,name' },
+ });
+ return whatsappClient.request<{ success: boolean }>({
+ accessToken,
+ method: HttpMethod.DELETE,
+ path: `/${context.auth.props.businessAccountId}/message_templates`,
+ queryParams: { hsm_id: template.id, name: template.name },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/download-media.ts b/packages/pieces/community/whatsapp/src/lib/actions/download-media.ts
new file mode 100644
index 000000000000..8dbef1f46dab
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/download-media.ts
@@ -0,0 +1,72 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { MediaInfo } from './get-media-url';
+import { downloadMediaOutputSchema } from '../output-schemas';
+
+export const downloadMedia = createAction({
+ auth: whatsappAuth,
+ name: 'download_media',
+ outputSchema: downloadMediaOutputSchema,
+ classification: 'READ',
+ displayName: 'Download Media',
+ description: 'Downloads a received media file so later steps can use it.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Downloads the file behind a WhatsApp media ID, typically one received on the New Incoming Message trigger, and returns it as a file plus its MIME type and size. Use this instead of Get Media URL when you need the content rather than a short-lived link. Idempotent — a pure read.',
+ idempotent: true,
+ },
+ props: {
+ media_id: whatsappProps.mediaId,
+ },
+ async run(context) {
+ const accessToken = context.auth.props.access_token;
+ const info = await whatsappClient.request({
+ accessToken,
+ method: HttpMethod.GET,
+ path: `/${context.propsValue.media_id}`,
+ });
+ const download = await httpClient.sendRequest({
+ method: HttpMethod.GET,
+ url: info.url,
+ authentication: {
+ type: AuthenticationType.BEARER_TOKEN,
+ token: accessToken,
+ },
+ responseType: 'stream',
+ });
+ const extension = mimeExtension(info.mime_type);
+ const fileName = `${info.id}${extension ? `.${extension}` : ''}`;
+ const file = await context.files.write({ fileName, data: download.body });
+ return {
+ file,
+ id: info.id,
+ mime_type: info.mime_type,
+ sha256: info.sha256,
+ file_size: info.file_size,
+ };
+ },
+});
+
+function mimeExtension(mimeType: string): string | undefined {
+ const subtype = mimeType.split('/')[1];
+ return MIME_EXTENSIONS[mimeType] ?? subtype;
+}
+
+const MIME_EXTENSIONS: Record = {
+ 'image/jpeg': 'jpg',
+ 'audio/mpeg': 'mp3',
+ 'audio/mp4': 'm4a',
+ 'audio/ogg': 'ogg',
+ 'video/3gpp': '3gp',
+ 'text/plain': 'txt',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'xlsx',
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
+ 'application/msword': 'doc',
+ 'application/vnd.ms-excel': 'xls',
+ 'application/vnd.ms-powerpoint': 'ppt',
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/edit-message-template.ts b/packages/pieces/community/whatsapp/src/lib/actions/edit-message-template.ts
new file mode 100644
index 000000000000..97ba30bb7fd8
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/edit-message-template.ts
@@ -0,0 +1,50 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { editMessageTemplateOutputSchema } from '../output-schemas';
+
+export const editMessageTemplate = createAction({
+ auth: whatsappAuth,
+ name: 'edit_message_template',
+ outputSchema: editMessageTemplateOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Edit Message Template',
+ description: 'Replaces the components of an existing template and resubmits it for review.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Edits an existing WhatsApp message template by replacing all of its components, optionally changing its category, and resubmits it for Meta review. Only APPROVED, REJECTED or PAUSED templates can be edited, and the whole components array is replaced rather than merged, so pass every component you want to keep. Idempotent — resending the same components converges on the same template.',
+ idempotent: true,
+ },
+ props: {
+ message_template_id: commonProps.message_template_id,
+ components: whatsappProps.templateComponents,
+ category: Property.StaticDropdown({
+ displayName: 'Category',
+ description: 'Leave empty to keep the current category.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Marketing', value: 'MARKETING' },
+ { label: 'Utility', value: 'UTILITY' },
+ { label: 'Authentication', value: 'AUTHENTICATION' },
+ ],
+ },
+ }),
+ },
+ async run(context) {
+ const { message_template_id, components, category } = context.propsValue;
+ return whatsappClient.request<{ success: boolean }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.POST,
+ path: `/${message_template_id}`,
+ body: {
+ components: whatsappClient.normalizeTemplateComponents(components),
+ ...(category ? { category } : {}),
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/get-business-profile.ts b/packages/pieces/community/whatsapp/src/lib/actions/get-business-profile.ts
new file mode 100644
index 000000000000..30e47bbece38
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/get-business-profile.ts
@@ -0,0 +1,46 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappClient } from '../common/client';
+import { getBusinessProfileOutputSchema } from '../output-schemas';
+
+export const getBusinessProfile = createAction({
+ auth: whatsappAuth,
+ name: 'get_business_profile',
+ outputSchema: getBusinessProfileOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Business Profile',
+ description: 'Returns the public business profile shown for a phone number.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Returns the WhatsApp business profile attached to a phone number: about text, address, description, email, websites, vertical and profile picture URL. Use it to read the current values before Update Business Profile. Idempotent — a pure read.',
+ idempotent: true,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ },
+ async run(context) {
+ const response = await whatsappClient.request<{ data: BusinessProfile[] }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.GET,
+ path: `/${context.propsValue.phone_number_id}/whatsapp_business_profile`,
+ queryParams: {
+ fields: 'about,address,description,email,profile_picture_url,websites,vertical',
+ },
+ });
+ return response.data[0];
+ },
+});
+
+export type BusinessProfile = {
+ messaging_product: string;
+ about?: string;
+ address?: string;
+ description?: string;
+ email?: string;
+ profile_picture_url?: string;
+ websites?: string[];
+ vertical?: string;
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/get-media-url.ts b/packages/pieces/community/whatsapp/src/lib/actions/get-media-url.ts
new file mode 100644
index 000000000000..2100956aa773
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/get-media-url.ts
@@ -0,0 +1,40 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { getMediaUrlOutputSchema } from '../output-schemas';
+
+export const getMediaUrl = createAction({
+ auth: whatsappAuth,
+ name: 'get_media_url',
+ outputSchema: getMediaUrlOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Media URL',
+ description: 'Looks up a media ID and returns its download URL and metadata.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Resolves a WhatsApp media ID to its temporary download URL, MIME type, size and SHA-256 hash. The URL expires after five minutes and needs the access token to fetch, so prefer Download Media when you want the file itself. Idempotent — a pure read.',
+ idempotent: true,
+ },
+ props: {
+ media_id: whatsappProps.mediaId,
+ },
+ async run(context) {
+ return whatsappClient.request({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.GET,
+ path: `/${context.propsValue.media_id}`,
+ });
+ },
+});
+
+export type MediaInfo = {
+ messaging_product: string;
+ url: string;
+ mime_type: string;
+ sha256: string;
+ file_size: number;
+ id: string;
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/get-phone-number.ts b/packages/pieces/community/whatsapp/src/lib/actions/get-phone-number.ts
new file mode 100644
index 000000000000..efc06fa38dab
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/get-phone-number.ts
@@ -0,0 +1,35 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappClient } from '../common/client';
+import { getPhoneNumberOutputSchema } from '../output-schemas';
+
+export const getPhoneNumber = createAction({
+ auth: whatsappAuth,
+ name: 'get_phone_number',
+ outputSchema: getPhoneNumberOutputSchema,
+ classification: 'READ',
+ displayName: 'Get Phone Number',
+ description: 'Returns the details and health of one business phone number.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Returns one WhatsApp business phone number with its verified name, quality rating, messaging throughput level, verification status and name status. Use it to check the sender health before a campaign; use List Phone Numbers to enumerate them. Idempotent — a pure read.',
+ idempotent: true,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ },
+ async run(context) {
+ return whatsappClient.request({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.GET,
+ path: `/${context.propsValue.phone_number_id}`,
+ queryParams: {
+ fields:
+ 'id,verified_name,display_phone_number,quality_rating,code_verification_status,name_status,status,throughput,platform_type',
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/list-message-templates.ts b/packages/pieces/community/whatsapp/src/lib/actions/list-message-templates.ts
new file mode 100644
index 000000000000..7f1d3400eb3f
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/list-message-templates.ts
@@ -0,0 +1,69 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { HttpMethod, QueryParams } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { whatsappClient } from '../common/client';
+import { listMessageTemplatesOutputSchema } from '../output-schemas';
+
+export const listMessageTemplates = createAction({
+ auth: whatsappAuth,
+ name: 'list_message_templates',
+ outputSchema: listMessageTemplatesOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Message Templates',
+ description: 'Lists the message templates on the business account, optionally filtered by status.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Lists the message templates registered on the WhatsApp Business Account with their id, name, language, category, status and components. Use it to find a template id or check approval state before Send Template Message. Filter by status (APPROVED, PENDING, REJECTED, PAUSED, DISABLED) to narrow the result. Idempotent — a pure read.',
+ idempotent: true,
+ },
+ props: {
+ status: Property.StaticDropdown({
+ displayName: 'Status',
+ description: 'Only return templates in this review state. Leave empty for all.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Approved', value: 'APPROVED' },
+ { label: 'Pending', value: 'PENDING' },
+ { label: 'Rejected', value: 'REJECTED' },
+ { label: 'Paused', value: 'PAUSED' },
+ { label: 'Disabled', value: 'DISABLED' },
+ ],
+ },
+ }),
+ limit: Property.Number({
+ displayName: 'Limit',
+ description: 'Maximum number of templates to return. Defaults to 100.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const { status, limit } = context.propsValue;
+ const queryParams: QueryParams = {
+ fields: 'id,name,language,category,status,components,quality_score',
+ limit: String(limit ?? 100),
+ };
+ if (status) queryParams['status'] = status;
+ const response = await whatsappClient.request<{ data: TemplateRecord[] }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.GET,
+ path: `/${context.auth.props.businessAccountId}/message_templates`,
+ queryParams,
+ });
+ return {
+ templates: response.data,
+ count: response.data.length,
+ };
+ },
+});
+
+type TemplateRecord = {
+ id: string;
+ name: string;
+ language: string;
+ category: string;
+ status: string;
+ components: unknown[];
+ quality_score?: { score: string };
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/list-phone-numbers.ts b/packages/pieces/community/whatsapp/src/lib/actions/list-phone-numbers.ts
new file mode 100644
index 000000000000..f0e9124cf72c
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/list-phone-numbers.ts
@@ -0,0 +1,70 @@
+import { createAction } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { whatsappClient } from '../common/client';
+import { listPhoneNumbersOutputSchema } from '../output-schemas';
+
+export const listPhoneNumbers = createAction({
+ auth: whatsappAuth,
+ name: 'list_phone_numbers',
+ outputSchema: listPhoneNumbersOutputSchema,
+ classification: 'SEARCH',
+ displayName: 'List Phone Numbers',
+ description: 'Lists the phone numbers registered on the business account.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Lists every phone number on the WhatsApp Business Account with its id, verified display name, formatted number and quality rating. Use it to resolve the phone number id that every send action requires. Idempotent — a pure read.',
+ idempotent: true,
+ },
+ props: {},
+ async run(context) {
+ const phoneNumbers = await fetchAllPhoneNumbers({
+ accessToken: context.auth.props.access_token,
+ businessAccountId: context.auth.props.businessAccountId,
+ });
+ return {
+ phone_numbers: phoneNumbers,
+ count: phoneNumbers.length,
+ };
+ },
+});
+
+async function fetchAllPhoneNumbers({ accessToken, businessAccountId }: FetchAllParams): Promise {
+ const pages: PhoneNumberRecord[][] = [];
+ let after: string | undefined = undefined;
+ for (let page = 0; page < MAX_PAGES; page++) {
+ const response: PhoneNumbersPage = await whatsappClient.request({
+ accessToken,
+ method: HttpMethod.GET,
+ path: `/${businessAccountId}/phone_numbers`,
+ queryParams: {
+ fields: 'id,verified_name,display_phone_number,quality_rating,code_verification_status,platform_type',
+ limit: String(PAGE_SIZE),
+ ...(after ? { after } : {}),
+ },
+ });
+ pages.push(response.data);
+ after = response.paging?.next ? response.paging.cursors?.after : undefined;
+ if (!after) break;
+ }
+ return pages.flat();
+}
+
+const PAGE_SIZE = 100;
+const MAX_PAGES = 50;
+
+type FetchAllParams = { accessToken: string; businessAccountId: string };
+type PhoneNumbersPage = {
+ data: PhoneNumberRecord[];
+ paging?: { cursors?: { before?: string; after?: string }; next?: string };
+};
+
+type PhoneNumberRecord = {
+ id: string;
+ verified_name: string;
+ display_phone_number: string;
+ quality_rating: string;
+ code_verification_status?: string;
+ platform_type?: string;
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/mark-message-as-read.ts b/packages/pieces/community/whatsapp/src/lib/actions/mark-message-as-read.ts
new file mode 100644
index 000000000000..00917680e53e
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/mark-message-as-read.ts
@@ -0,0 +1,46 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { markMessageAsReadOutputSchema } from '../output-schemas';
+
+export const markMessageAsRead = createAction({
+ auth: whatsappAuth,
+ name: 'mark_message_as_read',
+ outputSchema: markMessageAsReadOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Mark Message As Read',
+ description: 'Shows the sender blue ticks for a received message, optionally with a typing indicator.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Marks an incoming WhatsApp message as read so the sender sees blue ticks, and can show a typing indicator for up to 25 seconds while you prepare a reply. Use it right after the New Incoming Message trigger, before sending a response. Idempotent — marking an already-read message again is a no-op.',
+ idempotent: true,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ message_id: whatsappProps.messageId,
+ show_typing_indicator: Property.Checkbox({
+ displayName: 'Show Typing Indicator',
+ description: 'Display "typing…" to the sender until you reply or 25 seconds pass.',
+ required: false,
+ defaultValue: false,
+ }),
+ },
+ async run(context) {
+ const { phone_number_id, message_id, show_typing_indicator } = context.propsValue;
+ return whatsappClient.request<{ success: boolean }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.POST,
+ path: `/${phone_number_id}/messages`,
+ body: {
+ messaging_product: 'whatsapp',
+ status: 'read',
+ message_id,
+ ...(show_typing_indicator ? { typing_indicator: { type: 'text' } } : {}),
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-contact.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-contact.ts
new file mode 100644
index 000000000000..c75023dfdb64
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-contact.ts
@@ -0,0 +1,132 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { inputUtils } from '../common/inputs';
+import { messageSendOutputSchema } from '../output-schemas';
+
+export const sendContact = createAction({
+ auth: whatsappAuth,
+ name: 'send_contact',
+ outputSchema: messageSendOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Send Contact',
+ description: 'Sends a contact card the recipient can save.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Sends a WhatsApp contact card with a name and optional phone numbers, emails, company and website that the recipient can save to their address book. Choose this over Send Message when sharing a person or business as a savable contact. Free-form, so it only delivers inside the 24-hour customer service window. Not idempotent — each call sends a new message.',
+ idempotent: false,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ to: whatsappProps.to,
+ formatted_name: Property.ShortText({
+ displayName: 'Full Name',
+ description: 'Display name shown on the card.',
+ required: true,
+ }),
+ first_name: Property.ShortText({ displayName: 'First Name', required: false }),
+ last_name: Property.ShortText({ displayName: 'Last Name', required: false }),
+ phones: Property.Array({
+ displayName: 'Phone Numbers',
+ description: 'One entry per number the recipient can call or message.',
+ required: false,
+ properties: {
+ phone: Property.ShortText({
+ displayName: 'Phone',
+ description: 'Include the country code, for example +962791234567.',
+ required: true,
+ }),
+ type: Property.StaticDropdown({
+ displayName: 'Type',
+ description: 'Label shown next to the number.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Cell', value: 'CELL' },
+ { label: 'Main', value: 'MAIN' },
+ { label: 'Work', value: 'WORK' },
+ { label: 'Home', value: 'HOME' },
+ { label: 'iPhone', value: 'IPHONE' },
+ ],
+ },
+ }),
+ },
+ }),
+ emails: Property.Array({
+ displayName: 'Emails',
+ description: 'One entry per email address.',
+ required: false,
+ properties: {
+ email: Property.ShortText({ displayName: 'Email', required: true }),
+ type: Property.StaticDropdown({
+ displayName: 'Type',
+ description: 'Label shown next to the address.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Work', value: 'WORK' },
+ { label: 'Home', value: 'HOME' },
+ ],
+ },
+ }),
+ },
+ }),
+ company: Property.ShortText({ displayName: 'Company', required: false }),
+ job_title: Property.ShortText({ displayName: 'Job Title', required: false }),
+ website: Property.ShortText({ displayName: 'Website', required: false }),
+ reply_to_message_id: whatsappProps.replyToMessageId,
+ },
+ async run(context) {
+ const {
+ phone_number_id,
+ to,
+ formatted_name,
+ first_name,
+ last_name,
+ phones,
+ emails,
+ company,
+ job_title,
+ website,
+ reply_to_message_id,
+ } = context.propsValue;
+ const phoneEntries = inputUtils.asRecords(phones).map((entry) => ({
+ phone: inputUtils.requiredString({ record: entry, key: 'phone', label: 'Phone' }),
+ type: inputUtils.optionalString({ record: entry, key: 'type' }),
+ }));
+ const emailEntries = inputUtils.asRecords(emails).map((entry) => ({
+ email: inputUtils.requiredString({ record: entry, key: 'email', label: 'Email' }),
+ type: inputUtils.optionalString({ record: entry, key: 'type' }),
+ }));
+ const org = company || job_title ? { ...(company ? { company } : {}), ...(job_title ? { title: job_title } : {}) } : undefined;
+ return whatsappClient.sendMessage({
+ accessToken: context.auth.props.access_token,
+ phoneNumberId: phone_number_id,
+ to,
+ replyToMessageId: reply_to_message_id,
+ payload: {
+ type: 'contacts',
+ contacts: [
+ {
+ name: {
+ formatted_name,
+ ...(first_name ? { first_name } : {}),
+ ...(last_name ? { last_name } : {}),
+ },
+ ...(phoneEntries.length > 0
+ ? { phones: phoneEntries.map((entry) => ({ phone: entry.phone, ...(entry.type ? { type: entry.type } : {}) })) }
+ : {}),
+ ...(emailEntries.length > 0
+ ? { emails: emailEntries.map((entry) => ({ email: entry.email, ...(entry.type ? { type: entry.type } : {}) })) }
+ : {}),
+ ...(org ? { org } : {}),
+ ...(website ? { urls: [{ url: website, type: 'WORK' }] } : {}),
+ },
+ ],
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts
index d7b1d8765ff0..329a2600a680 100644
--- a/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-from-template.ts
@@ -1,15 +1,17 @@
import { whatsappAuth } from '../auth';
import { AuthenticationType, httpClient, HttpMethod } from '@activepieces/pieces-common';
import { createAction, Property } from '@activepieces/pieces-framework';
-import { commonProps } from '../common/utils';
+import { commonProps, WHATSAPP_API_BASE } from '../common/utils';
+import { messageSendOutputSchema } from '../output-schemas';
export const sendTemplateMessageAction = createAction({
auth: whatsappAuth,
name: 'send-template-message',
+ outputSchema: messageSendOutputSchema,
classification: 'WRITE',
displayName: 'Send Template Message',
description: 'Send an approved template, filling in its placeholders.',
- audience: 'both',
+ audience: 'human',
aiMetadata: { description: 'Sends a pre-approved WhatsApp message template to a recipient, filling its header, body, and button placeholders from the supplied fields. Choose this to initiate conversations outside the 24-hour customer service window or for notifications/marketing where a registered template is required; the template must already be approved in the WhatsApp Business account. Requires the sender phone number ID, recipient phone number, and the template ID (its name and language are resolved automatically). Not idempotent — each call delivers a new message.', idempotent: false },
props: {
phone_number_id: commonProps.phone_number_id,
@@ -69,7 +71,7 @@ export const sendTemplateMessageAction = createAction({
// fetch template language code
const templateData = await httpClient.sendRequest({
method: HttpMethod.GET,
- url: `https://graph.facebook.com/v20.0/${templateId}`,
+ url: `${WHATSAPP_API_BASE}/${templateId}`,
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: context.auth.props.access_token,
@@ -85,7 +87,7 @@ export const sendTemplateMessageAction = createAction({
// https://developers.facebook.com/docs/whatsapp/cloud-api/guides/send-message-templates/#text-based
const response = await httpClient.sendRequest({
method: HttpMethod.POST,
- url: `https://graph.facebook.com/v20.0/${phoneNumberId}/messages`,
+ url: `${WHATSAPP_API_BASE}/${phoneNumberId}/messages`,
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: context.auth.props.access_token,
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-buttons.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-buttons.ts
new file mode 100644
index 000000000000..43713d5ab5dd
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-buttons.ts
@@ -0,0 +1,91 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { inputUtils } from '../common/inputs';
+import { messageSendOutputSchema } from '../output-schemas';
+
+export const sendInteractiveButtons = createAction({
+ auth: whatsappAuth,
+ name: 'send_interactive_buttons',
+ outputSchema: messageSendOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Send Interactive Buttons',
+ description: 'Sends a message with up to three quick-reply buttons.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Sends a WhatsApp message with up to three tappable reply buttons; the recipient tap arrives as an incoming interactive message carrying the button id. Choose this over Send Message when you need a structured yes/no or menu-style answer, and over Send Interactive List when there are three options or fewer. Free-form, so it only delivers inside the 24-hour customer service window. Not idempotent — each call sends a new message.',
+ idempotent: false,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ to: whatsappProps.to,
+ body: whatsappProps.bodyText,
+ buttons: Property.Array({
+ displayName: 'Buttons',
+ description: 'Between one and three buttons. Titles must be unique and at most 20 characters.',
+ required: true,
+ properties: {
+ id: Property.ShortText({
+ displayName: 'Button ID',
+ description: 'Returned to you when the recipient taps this button. Max 256 characters.',
+ required: true,
+ }),
+ title: Property.ShortText({
+ displayName: 'Title',
+ description: 'Text on the button. Max 20 characters, unique across the buttons.',
+ required: true,
+ }),
+ },
+ }),
+ header_type: whatsappProps.interactiveHeaderType,
+ header_text: whatsappProps.interactiveHeaderText,
+ header_media_url: whatsappProps.interactiveHeaderMediaUrl,
+ footer: whatsappProps.footerText,
+ reply_to_message_id: whatsappProps.replyToMessageId,
+ },
+ async run(context) {
+ const { phone_number_id, to, body, buttons, header_type, header_text, header_media_url, footer, reply_to_message_id } =
+ context.propsValue;
+ const buttonEntries = inputUtils.asRecords(buttons).map((entry) => ({
+ id: inputUtils.requiredString({ record: entry, key: 'id', label: 'Button ID', maxLength: 256 }),
+ title: inputUtils.requiredString({ record: entry, key: 'title', label: 'Button title', maxLength: 20 }),
+ }));
+ if (buttonEntries.length < 1 || buttonEntries.length > 3) {
+ throw new Error('Interactive button messages need between 1 and 3 buttons.');
+ }
+ inputUtils.assertUnique({ values: buttonEntries.map((button) => button.title), label: 'Button titles' });
+ inputUtils.assertUnique({ values: buttonEntries.map((button) => button.id), label: 'Button IDs' });
+ inputUtils.assertMaxLength({ value: body, maxLength: 1024, label: 'Body' });
+ inputUtils.assertMaxLength({ value: footer, maxLength: 60, label: 'Footer' });
+ inputUtils.assertMaxLength({ value: header_text, maxLength: 60, label: 'Header text' });
+ const header = whatsappClient.buildInteractiveHeader({
+ headerType: header_type,
+ headerText: header_text,
+ headerMediaUrl: header_media_url,
+ });
+ return whatsappClient.sendMessage({
+ accessToken: context.auth.props.access_token,
+ phoneNumberId: phone_number_id,
+ to,
+ replyToMessageId: reply_to_message_id,
+ payload: {
+ type: 'interactive',
+ interactive: {
+ type: 'button',
+ ...(header ? { header } : {}),
+ body: { text: body },
+ ...(footer ? { footer: { text: footer } } : {}),
+ action: {
+ buttons: buttonEntries.map((button) => ({
+ type: 'reply',
+ reply: { id: button.id, title: button.title },
+ })),
+ },
+ },
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-cta-url.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-cta-url.ts
new file mode 100644
index 000000000000..2d51c7fe5775
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-cta-url.ts
@@ -0,0 +1,74 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { inputUtils } from '../common/inputs';
+import { messageSendOutputSchema } from '../output-schemas';
+
+export const sendInteractiveCtaUrl = createAction({
+ auth: whatsappAuth,
+ name: 'send_interactive_cta_url',
+ outputSchema: messageSendOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Send Interactive CTA URL',
+ description: 'Sends a message with a single button that opens a web link.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Sends a WhatsApp message with one call-to-action button that opens a URL, without needing an approved template. Choose this over Send Message when the recipient should tap through to a page rather than read a raw link. Free-form, so it only delivers inside the 24-hour customer service window. Not idempotent — each call sends a new message.',
+ idempotent: false,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ to: whatsappProps.to,
+ body: whatsappProps.bodyText,
+ button_text: Property.ShortText({
+ displayName: 'Button Text',
+ description: 'Label of the button. Max 20 characters.',
+ required: true,
+ }),
+ url: Property.ShortText({
+ displayName: 'Button URL',
+ description: 'The link the button opens.',
+ required: true,
+ }),
+ header_type: whatsappProps.interactiveHeaderType,
+ header_text: whatsappProps.interactiveHeaderText,
+ header_media_url: whatsappProps.interactiveHeaderMediaUrl,
+ footer: whatsappProps.footerText,
+ reply_to_message_id: whatsappProps.replyToMessageId,
+ },
+ async run(context) {
+ const { phone_number_id, to, body, button_text, url, header_type, header_text, header_media_url, footer, reply_to_message_id } =
+ context.propsValue;
+ inputUtils.assertMaxLength({ value: button_text, maxLength: 20, label: 'Button text' });
+ inputUtils.assertMaxLength({ value: body, maxLength: 1024, label: 'Body' });
+ inputUtils.assertMaxLength({ value: footer, maxLength: 60, label: 'Footer' });
+ inputUtils.assertMaxLength({ value: header_text, maxLength: 60, label: 'Header text' });
+ const header = whatsappClient.buildInteractiveHeader({
+ headerType: header_type,
+ headerText: header_text,
+ headerMediaUrl: header_media_url,
+ });
+ return whatsappClient.sendMessage({
+ accessToken: context.auth.props.access_token,
+ phoneNumberId: phone_number_id,
+ to,
+ replyToMessageId: reply_to_message_id,
+ payload: {
+ type: 'interactive',
+ interactive: {
+ type: 'cta_url',
+ ...(header ? { header } : {}),
+ body: { text: body },
+ ...(footer ? { footer: { text: footer } } : {}),
+ action: {
+ name: 'cta_url',
+ parameters: { display_text: button_text, url },
+ },
+ },
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-list.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-list.ts
new file mode 100644
index 000000000000..9977945abc9f
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-interactive-list.ts
@@ -0,0 +1,106 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { inputUtils } from '../common/inputs';
+import { messageSendOutputSchema } from '../output-schemas';
+
+export const sendInteractiveList = createAction({
+ auth: whatsappAuth,
+ name: 'send_interactive_list',
+ outputSchema: messageSendOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Send Interactive List',
+ description: 'Sends a message that opens a menu of up to ten selectable rows.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Sends a WhatsApp list message: a button that opens a menu of up to ten rows grouped in sections, and the recipient selection arrives as an incoming interactive message carrying the row id. Choose this over Send Interactive Buttons when there are more than three options. Free-form, so it only delivers inside the 24-hour customer service window. Not idempotent — each call sends a new message.',
+ idempotent: false,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ to: whatsappProps.to,
+ body: whatsappProps.bodyText,
+ button_text: Property.ShortText({
+ displayName: 'Button Text',
+ description: 'Label of the button that opens the list. Max 20 characters.',
+ required: true,
+ }),
+ section_title: Property.ShortText({
+ displayName: 'Section Title',
+ description: 'Title shown above the rows. Max 24 characters.',
+ required: true,
+ }),
+ rows: Property.Array({
+ displayName: 'Rows',
+ description: 'Between one and ten rows. Titles max 24 characters, descriptions max 72.',
+ required: true,
+ properties: {
+ id: Property.ShortText({
+ displayName: 'Row ID',
+ description: 'Returned to you when the recipient selects this row. Max 200 characters.',
+ required: true,
+ }),
+ title: Property.ShortText({
+ displayName: 'Title',
+ required: true,
+ }),
+ description: Property.ShortText({
+ displayName: 'Description',
+ required: false,
+ }),
+ },
+ }),
+ header_text: whatsappProps.interactiveHeaderText,
+ footer: whatsappProps.footerText,
+ reply_to_message_id: whatsappProps.replyToMessageId,
+ },
+ async run(context) {
+ const { phone_number_id, to, body, button_text, section_title, rows, header_text, footer, reply_to_message_id } =
+ context.propsValue;
+ const rowEntries = inputUtils.asRecords(rows).map((entry) => ({
+ id: inputUtils.requiredString({ record: entry, key: 'id', label: 'Row ID', maxLength: 200 }),
+ title: inputUtils.requiredString({ record: entry, key: 'title', label: 'Row title', maxLength: 24 }),
+ description: inputUtils.optionalString({ record: entry, key: 'description', label: 'Row description', maxLength: 72 }),
+ }));
+ if (rowEntries.length < 1 || rowEntries.length > 10) {
+ throw new Error('Interactive list messages need between 1 and 10 rows.');
+ }
+ inputUtils.assertUnique({ values: rowEntries.map((row) => row.id), label: 'Row IDs' });
+ inputUtils.assertMaxLength({ value: button_text, maxLength: 20, label: 'Button text' });
+ inputUtils.assertMaxLength({ value: section_title, maxLength: 24, label: 'Section title' });
+ inputUtils.assertMaxLength({ value: body, maxLength: 4096, label: 'Body' });
+ inputUtils.assertMaxLength({ value: footer, maxLength: 60, label: 'Footer' });
+ inputUtils.assertMaxLength({ value: header_text, maxLength: 60, label: 'Header text' });
+ return whatsappClient.sendMessage({
+ accessToken: context.auth.props.access_token,
+ phoneNumberId: phone_number_id,
+ to,
+ replyToMessageId: reply_to_message_id,
+ payload: {
+ type: 'interactive',
+ interactive: {
+ type: 'list',
+ ...(header_text ? { header: { type: 'text', text: header_text } } : {}),
+ body: { text: body },
+ ...(footer ? { footer: { text: footer } } : {}),
+ action: {
+ button: button_text,
+ sections: [
+ {
+ title: section_title,
+ rows: rowEntries.map((row) => ({
+ id: row.id,
+ title: row.title,
+ ...(row.description ? { description: row.description } : {}),
+ })),
+ },
+ ],
+ },
+ },
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-location.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-location.ts
new file mode 100644
index 000000000000..625a7cad41b0
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-location.ts
@@ -0,0 +1,64 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { messageSendOutputSchema } from '../output-schemas';
+
+export const sendLocation = createAction({
+ auth: whatsappAuth,
+ name: 'send_location',
+ outputSchema: messageSendOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Send Location',
+ description: 'Sends a map pin for a latitude and longitude.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Sends a WhatsApp location message that renders as a map pin, with an optional place name and address. Choose this over Send Message when the payload is a physical place rather than text. Free-form, so it only delivers inside the 24-hour customer service window. Not idempotent — each call sends a new message.',
+ idempotent: false,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ to: whatsappProps.to,
+ latitude: Property.Number({
+ displayName: 'Latitude',
+ description: 'Decimal degrees, for example 31.9539.',
+ required: true,
+ }),
+ longitude: Property.Number({
+ displayName: 'Longitude',
+ description: 'Decimal degrees, for example 35.9106.',
+ required: true,
+ }),
+ name: Property.ShortText({
+ displayName: 'Location Name',
+ description: 'Title shown under the map pin, for example a venue or shop name.',
+ required: false,
+ }),
+ address: Property.ShortText({
+ displayName: 'Address',
+ description: 'Street address shown under the name. Only displayed when a name is also set.',
+ required: false,
+ }),
+ reply_to_message_id: whatsappProps.replyToMessageId,
+ },
+ async run(context) {
+ const { phone_number_id, to, latitude, longitude, name, address, reply_to_message_id } = context.propsValue;
+ return whatsappClient.sendMessage({
+ accessToken: context.auth.props.access_token,
+ phoneNumberId: phone_number_id,
+ to,
+ replyToMessageId: reply_to_message_id,
+ payload: {
+ type: 'location',
+ location: {
+ latitude: String(latitude),
+ longitude: String(longitude),
+ ...(name ? { name } : {}),
+ ...(address ? { address } : {}),
+ },
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts
index a827f4ed1e33..865fae8aace0 100644
--- a/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-media.ts
@@ -6,11 +6,14 @@ import {
capitalizeFirstLetter,
mediaTypeSupportsCaption,
commonProps,
+ WHATSAPP_API_BASE,
} from '../common/utils';
+import { messageSendOutputSchema } from '../output-schemas';
export const sendMedia = createAction({
auth: whatsappAuth,
name: 'sendMedia',
+ outputSchema: messageSendOutputSchema,
classification: 'WRITE',
displayName: 'Send Media',
description: 'Send an image, video, audio, document or sticker by URL.',
@@ -60,24 +63,26 @@ export const sendMedia = createAction({
async run(context) {
const { to, caption, media, type, filename, phone_number_id } = context.propsValue;
const { access_token } = context.auth.props;
+ const mediaObject = {
+ link: media,
+ ...(caption && mediaTypeSupportsCaption(type) ? { caption } : {}),
+ ...(filename && type === 'document' ? { filename } : {}),
+ };
const body = {
messaging_product: 'whatsapp',
recipient_type: 'individual',
to,
type,
- [type]: {
- link: media,
- },
+ [type]: mediaObject,
};
- if (caption && mediaTypeSupportsCaption(type)) (body[type] as any).caption = caption;
- if (filename && type === 'document') (body[type] as any).filename = filename;
- return await httpClient.sendRequest({
+ const response = await httpClient.sendRequest({
method: HttpMethod.POST,
- url: `https://graph.facebook.com/v17.0/${phone_number_id}/messages`,
+ url: `${WHATSAPP_API_BASE}/${phone_number_id}/messages`,
headers: {
Authorization: 'Bearer ' + access_token,
},
body,
});
+ return response.body;
},
});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts
index b5743ad01e7a..00bc9a71e870 100644
--- a/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-message.ts
@@ -1,11 +1,13 @@
import { createAction, Property } from '@activepieces/pieces-framework';
import { httpClient, HttpMethod } from '@activepieces/pieces-common';
import { whatsappAuth } from '../auth';
-import { commonProps } from '../common/utils';
+import { commonProps, WHATSAPP_API_BASE } from '../common/utils';
+import { messageSendOutputSchema } from '../output-schemas';
export const sendMessage = createAction({
auth: whatsappAuth,
name: 'sendMessage',
+ outputSchema: messageSendOutputSchema,
classification: 'WRITE',
displayName: 'Send Message',
description: 'Send a text message through WhatsApp',
@@ -28,9 +30,9 @@ export const sendMessage = createAction({
async run(context) {
const { to, text, phone_number_id } = context.propsValue;
const { access_token } = context.auth.props;
- return await httpClient.sendRequest({
+ const response = await httpClient.sendRequest({
method: HttpMethod.POST,
- url: `https://graph.facebook.com/v17.0/${phone_number_id}/messages`,
+ url: `${WHATSAPP_API_BASE}/${phone_number_id}/messages`,
headers: {
Authorization: 'Bearer ' + access_token,
},
@@ -44,5 +46,6 @@ export const sendMessage = createAction({
},
},
});
+ return response.body;
},
});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-reaction.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-reaction.ts
new file mode 100644
index 000000000000..1b0d8961a0c2
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-reaction.ts
@@ -0,0 +1,43 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { messageSendOutputSchema } from '../output-schemas';
+
+export const sendReaction = createAction({
+ auth: whatsappAuth,
+ name: 'send_reaction',
+ outputSchema: messageSendOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Send Reaction',
+ description: 'Reacts to a message with an emoji, or removes an existing reaction.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Adds an emoji reaction to a specific WhatsApp message, or removes the reaction when the emoji is left empty. Choose this over Send Message to acknowledge a message without sending a new bubble. The target must be a message from the last 30 days that is not itself a reaction. Idempotent — reacting again with the same emoji leaves the same single reaction in place.',
+ idempotent: true,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ to: whatsappProps.to,
+ message_id: whatsappProps.messageId,
+ emoji: Property.ShortText({
+ displayName: 'Emoji',
+ description: 'A single emoji such as 👍. Leave empty to remove your reaction.',
+ required: false,
+ }),
+ },
+ async run(context) {
+ const { phone_number_id, to, message_id, emoji } = context.propsValue;
+ return whatsappClient.sendMessage({
+ accessToken: context.auth.props.access_token,
+ phoneNumberId: phone_number_id,
+ to,
+ payload: {
+ type: 'reaction',
+ reaction: { message_id, emoji: emoji ?? '' },
+ },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/send-template.ts b/packages/pieces/community/whatsapp/src/lib/actions/send-template.ts
new file mode 100644
index 000000000000..fc7042ec8745
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/send-template.ts
@@ -0,0 +1,83 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { inputUtils } from '../common/inputs';
+import { messageSendOutputSchema } from '../output-schemas';
+
+export const sendTemplate = createAction({
+ auth: whatsappAuth,
+ name: 'send_template',
+ outputSchema: messageSendOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Send Template',
+ description: 'Sends an approved template by name with positional parameters.',
+ audience: 'ai',
+ aiMetadata: {
+ description:
+ 'Sends a pre-approved WhatsApp message template identified by its name and language code, filling the numbered {{1}}, {{2}} placeholders from plain parameter lists for the header, body and URL button. Use this to start a conversation outside the 24-hour window or for notifications; resolve the template name, language and placeholder count with List Message Templates first. Only text parameters are supported. Not idempotent — each call sends a new message.',
+ idempotent: false,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ to: whatsappProps.to,
+ template_name: Property.ShortText({
+ displayName: 'Template Name',
+ description: 'Exact template name, for example hello_world. Get it from List Message Templates.',
+ required: true,
+ }),
+ language_code: Property.ShortText({
+ displayName: 'Language Code',
+ description: 'Language and locale of the approved template, for example en_US.',
+ required: true,
+ defaultValue: 'en_US',
+ }),
+ body_parameters: Property.Array({
+ displayName: 'Body Parameters',
+ description: 'Values for the body placeholders, in order: the first entry fills {{1}}, the second {{2}}.',
+ required: false,
+ }),
+ header_parameters: Property.Array({
+ displayName: 'Header Parameters',
+ description: 'Values for text header placeholders, in order.',
+ required: false,
+ }),
+ button_url_parameters: Property.Array({
+ displayName: 'Button URL Parameters',
+ description: 'Values for the dynamic URL suffix of the first URL button, in order.',
+ required: false,
+ }),
+ reply_to_message_id: whatsappProps.replyToMessageId,
+ },
+ async run(context) {
+ const { phone_number_id, to, template_name, language_code, body_parameters, header_parameters, button_url_parameters, reply_to_message_id } =
+ context.propsValue;
+ const body = inputUtils.asStrings(body_parameters);
+ const header = inputUtils.asStrings(header_parameters);
+ const button = inputUtils.asStrings(button_url_parameters);
+ const components = [
+ ...(header.length > 0 ? [{ type: 'header', parameters: textParameters(header) }] : []),
+ ...(body.length > 0 ? [{ type: 'body', parameters: textParameters(body) }] : []),
+ ...(button.length > 0 ? [{ type: 'button', sub_type: 'url', index: 0, parameters: textParameters(button) }] : []),
+ ];
+ return whatsappClient.sendMessage({
+ accessToken: context.auth.props.access_token,
+ phoneNumberId: phone_number_id,
+ to,
+ replyToMessageId: reply_to_message_id,
+ payload: {
+ type: 'template',
+ template: {
+ name: template_name,
+ language: { code: language_code },
+ ...(components.length > 0 ? { components } : {}),
+ },
+ },
+ });
+ },
+});
+
+function textParameters(values: string[]): { type: 'text'; text: string }[] {
+ return values.map((text) => ({ type: 'text', text }));
+}
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/update-business-profile.ts b/packages/pieces/community/whatsapp/src/lib/actions/update-business-profile.ts
new file mode 100644
index 000000000000..b1bc9e34c5a2
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/update-business-profile.ts
@@ -0,0 +1,59 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappProps } from '../common/props';
+import { whatsappClient } from '../common/client';
+import { inputUtils } from '../common/inputs';
+import { updateBusinessProfileOutputSchema } from '../output-schemas';
+
+export const updateBusinessProfile = createAction({
+ auth: whatsappAuth,
+ name: 'update_business_profile',
+ outputSchema: updateBusinessProfileOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Update Business Profile',
+ description: 'Updates the public business profile for a phone number. Empty fields are left unchanged.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Updates the WhatsApp business profile of a phone number: about, address, description, email, websites and vertical. Only the fields you fill are sent, so existing values you leave empty are kept. Use Get Business Profile first to see the current values. Idempotent — resending the same values leaves the profile unchanged.',
+ idempotent: true,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ about: Property.ShortText({
+ displayName: 'About',
+ description: 'Short status line, max 139 characters.',
+ required: false,
+ }),
+ address: Property.ShortText({ displayName: 'Address', required: false }),
+ description: Property.LongText({ displayName: 'Description', required: false }),
+ email: Property.ShortText({ displayName: 'Email', required: false }),
+ websites: Property.Array({
+ displayName: 'Websites',
+ description: 'Up to two URLs.',
+ required: false,
+ }),
+ vertical: whatsappProps.businessVertical,
+ },
+ async run(context) {
+ const { phone_number_id, about, address, description, email, websites, vertical } = context.propsValue;
+ const websiteEntries = inputUtils.asStrings(websites);
+ const response = await whatsappClient.request<{ success: boolean }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.POST,
+ path: `/${phone_number_id}/whatsapp_business_profile`,
+ body: {
+ messaging_product: 'whatsapp',
+ ...(about ? { about } : {}),
+ ...(address ? { address } : {}),
+ ...(description ? { description } : {}),
+ ...(email ? { email } : {}),
+ ...(websiteEntries.length > 0 ? { websites: websiteEntries } : {}),
+ ...(vertical ? { vertical } : {}),
+ },
+ });
+ return response;
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/actions/upload-media.ts b/packages/pieces/community/whatsapp/src/lib/actions/upload-media.ts
new file mode 100644
index 000000000000..63814458b403
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/actions/upload-media.ts
@@ -0,0 +1,67 @@
+import { createAction, Property } from '@activepieces/pieces-framework';
+import { HttpMethod } from '@activepieces/pieces-common';
+import { whatsappAuth } from '../auth';
+import { commonProps } from '../common/utils';
+import { whatsappClient } from '../common/client';
+import { uploadMediaOutputSchema } from '../output-schemas';
+
+export const uploadMedia = createAction({
+ auth: whatsappAuth,
+ name: 'upload_media',
+ outputSchema: uploadMediaOutputSchema,
+ classification: 'WRITE',
+ displayName: 'Upload Media',
+ description: 'Uploads a file to WhatsApp and returns a media ID you can send.',
+ audience: 'both',
+ aiMetadata: {
+ description:
+ 'Uploads a file from a previous step to WhatsApp and returns a media ID that stays valid for 30 days. Use this before Send Media when the file is not reachable at a public URL. Images max 5 MB, audio and video max 16 MB, documents max 100 MB. Not idempotent — each call stores a new copy and returns a new ID.',
+ idempotent: false,
+ },
+ props: {
+ phone_number_id: commonProps.phone_number_id,
+ file: Property.File({
+ displayName: 'File',
+ description: 'The file to upload, from a previous step or a URL.',
+ required: true,
+ }),
+ mime_type: Property.StaticDropdown({
+ displayName: 'File Type',
+ description: 'Must match the actual file contents; WhatsApp rejects mismatches.',
+ required: true,
+ options: {
+ options: [
+ { label: 'JPEG image', value: 'image/jpeg' },
+ { label: 'PNG image', value: 'image/png' },
+ { label: 'WebP sticker', value: 'image/webp' },
+ { label: 'MP4 video', value: 'video/mp4' },
+ { label: '3GPP video', value: 'video/3gpp' },
+ { label: 'AAC audio', value: 'audio/aac' },
+ { label: 'AMR audio', value: 'audio/amr' },
+ { label: 'MP3 audio', value: 'audio/mpeg' },
+ { label: 'MP4 audio', value: 'audio/mp4' },
+ { label: 'OGG audio', value: 'audio/ogg' },
+ { label: 'PDF document', value: 'application/pdf' },
+ { label: 'Plain text', value: 'text/plain' },
+ { label: 'Word document', value: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
+ { label: 'Excel spreadsheet', value: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
+ { label: 'PowerPoint presentation', value: 'application/vnd.openxmlformats-officedocument.presentationml.presentation' },
+ ],
+ },
+ }),
+ },
+ async run(context) {
+ const { phone_number_id, file, mime_type } = context.propsValue;
+ const multipart = whatsappClient.buildMultipartBody({
+ fields: { messaging_product: 'whatsapp', type: mime_type },
+ file: { field: 'file', filename: file.filename, contentType: mime_type, data: file.data },
+ });
+ return whatsappClient.request<{ id: string }>({
+ accessToken: context.auth.props.access_token,
+ method: HttpMethod.POST,
+ path: `/${phone_number_id}/media`,
+ body: multipart.body,
+ headers: { 'Content-Type': multipart.contentType },
+ });
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/auth.ts b/packages/pieces/community/whatsapp/src/lib/auth.ts
index 2248f4b7d633..aa11216de56c 100644
--- a/packages/pieces/community/whatsapp/src/lib/auth.ts
+++ b/packages/pieces/community/whatsapp/src/lib/auth.ts
@@ -7,6 +7,8 @@ You need the WhatsApp Business Account ID and a permanent System User access tok
2. Add the **WhatsApp** product to the app.
3. Go to **WhatsApp > API Setup** and copy the **WhatsApp Business Account ID**.
4. In [Meta Business Manager](https://business.facebook.com/) open **Settings > System Users**, add a system user with access to the app, and generate a permanent token with the **whatsapp_business_messaging** and **whatsapp_business_management** permissions.
+
+Optional: to have Activepieces verify that webhook deliveries really come from Meta, also paste your **App Secret** (App settings > Basic > App Secret). Triggers then reject any request that is not signed with it.
`;
export const whatsappAuth = PieceAuth.CustomAuth({
@@ -24,5 +26,10 @@ export const whatsappAuth = PieceAuth.CustomAuth({
placeholder: '102290129340398',
required: true,
}),
+ app_secret: PieceAuth.SecretText({
+ displayName: 'App Secret',
+ description: 'Optional. Your Meta app secret (App settings > Basic). When set, every webhook delivery must carry a valid X-Hub-Signature-256 or it is ignored.',
+ required: false,
+ }),
},
});
diff --git a/packages/pieces/community/whatsapp/src/lib/common/client.ts b/packages/pieces/community/whatsapp/src/lib/common/client.ts
new file mode 100644
index 000000000000..ddfb61bfe338
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/common/client.ts
@@ -0,0 +1,148 @@
+import { randomBytes } from 'crypto';
+import {
+ AuthenticationType,
+ httpClient,
+ HttpMethod,
+ HttpRequest,
+ QueryParams,
+} from '@activepieces/pieces-common';
+import { WHATSAPP_API_BASE } from './utils';
+
+async function request>({
+ accessToken,
+ method,
+ path,
+ body,
+ queryParams,
+ headers,
+}: RequestParams): Promise {
+ const httpRequest: HttpRequest = {
+ method,
+ url: `${WHATSAPP_API_BASE}${path}`,
+ authentication: {
+ type: AuthenticationType.BEARER_TOKEN,
+ token: accessToken,
+ },
+ body,
+ queryParams,
+ headers,
+ };
+ const response = await httpClient.sendRequest(httpRequest);
+ return response.body;
+}
+
+async function sendMessage({
+ accessToken,
+ phoneNumberId,
+ to,
+ replyToMessageId,
+ payload,
+}: SendMessageParams): Promise {
+ return request({
+ accessToken,
+ method: HttpMethod.POST,
+ path: `/${phoneNumberId}/messages`,
+ body: {
+ messaging_product: 'whatsapp',
+ recipient_type: 'individual',
+ to,
+ ...(replyToMessageId ? { context: { message_id: replyToMessageId } } : {}),
+ ...payload,
+ },
+ });
+}
+
+function buildMultipartBody({ fields, file }: MultipartParams): { body: Buffer; contentType: string } {
+ const boundary = `----ActivepiecesWhatsApp${randomBytes(12).toString('hex')}`;
+ const fieldParts = Object.entries(fields).map(([name, value]) =>
+ Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}`, 'utf8'),
+ );
+ const safeFilename = file.filename.replace(/["\r\n]/g, '_');
+ const fileHeader = Buffer.from(
+ `--${boundary}${CRLF}Content-Disposition: form-data; name="${file.field}"; filename="${safeFilename}"${CRLF}Content-Type: ${file.contentType}${CRLF}${CRLF}`,
+ 'utf8',
+ );
+ const closing = Buffer.from(`${CRLF}--${boundary}--${CRLF}`, 'utf8');
+ return {
+ body: Buffer.concat([...fieldParts, fileHeader, file.data, closing]),
+ contentType: `multipart/form-data; boundary=${boundary}`,
+ };
+}
+
+const CRLF = '\r\n';
+
+
+function buildInteractiveHeader({
+ headerType,
+ headerText,
+ headerMediaUrl,
+}: InteractiveHeaderParams): Record | undefined {
+ if (!headerType || headerType === 'none') return undefined;
+ if (headerType === 'text') {
+ if (!headerText) {
+ throw new Error('Header Text is required when the header type is text.');
+ }
+ return { type: 'text', text: headerText };
+ }
+ if (!headerMediaUrl) {
+ throw new Error(`Header Media URL is required when the header type is ${headerType}.`);
+ }
+ return { type: headerType, [headerType]: { link: headerMediaUrl } };
+}
+
+function normalizeTemplateComponents(components: unknown): unknown {
+ if (typeof components === 'string') {
+ return components.replace(/\[\[(\w+)\]\]/g, '{{$1}}');
+ }
+ if (Array.isArray(components)) {
+ return components.map((item) => normalizeTemplateComponents(item));
+ }
+ if (components && typeof components === 'object') {
+ return Object.fromEntries(
+ Object.entries(components).map(([key, value]) => [key, normalizeTemplateComponents(value)]),
+ );
+ }
+ return components;
+}
+
+export const whatsappClient = {
+ buildMultipartBody,
+ request,
+ sendMessage,
+ buildInteractiveHeader,
+ normalizeTemplateComponents,
+};
+
+export type MessageSendResponse = {
+ messaging_product: string;
+ contacts: { input: string; wa_id: string }[];
+ messages: { id: string; message_status?: string }[];
+};
+
+type RequestParams = {
+ accessToken: string;
+ method: HttpMethod;
+ path: string;
+ body?: unknown;
+ queryParams?: QueryParams;
+ headers?: Record;
+};
+
+type SendMessageParams = {
+ accessToken: string;
+ phoneNumberId: string;
+ to: string;
+ replyToMessageId?: string;
+ payload: Record;
+};
+
+type InteractiveHeaderParams = {
+ headerType?: string;
+ headerText?: string;
+ headerMediaUrl?: string;
+};
+
+type MultipartParams = {
+ fields: Record;
+ file: { field: string; filename: string; contentType: string; data: Buffer };
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/common/inputs.ts b/packages/pieces/community/whatsapp/src/lib/common/inputs.ts
new file mode 100644
index 000000000000..127604a2ca6a
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/common/inputs.ts
@@ -0,0 +1,70 @@
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function parseIfJsonString(value: unknown): unknown {
+ if (typeof value !== 'string') return value;
+ const trimmed = value.trim();
+ if (!trimmed.startsWith('[') && !trimmed.startsWith('{')) return value;
+ try {
+ return JSON.parse(trimmed);
+ } catch {
+ return value;
+ }
+}
+
+function asRecords(value: unknown): Record[] {
+ const parsed = parseIfJsonString(value);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter(isRecord);
+}
+
+function asStrings(value: unknown): string[] {
+ const parsed = parseIfJsonString(value);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter((item): item is string => typeof item === 'string' && item.length > 0);
+}
+
+function assertMaxLength({ value, maxLength, label }: AssertMaxLengthParams): void {
+ if (value && value.length > maxLength) {
+ throw new Error(`${label} must be at most ${maxLength} characters (got ${value.length}).`);
+ }
+}
+
+function assertUnique({ values, label }: AssertUniqueParams): void {
+ if (new Set(values).size !== values.length) {
+ throw new Error(`${label} must be unique.`);
+ }
+}
+
+function optionalString({ record, key, label, maxLength }: OptionalFieldParams): string | undefined {
+ const value = record[key];
+ const text = typeof value === 'string' && value.length > 0 ? value : undefined;
+ if (maxLength !== undefined) {
+ assertMaxLength({ value: text, maxLength, label: label ?? key });
+ }
+ return text;
+}
+
+function requiredString({ record, key, label, maxLength }: RequiredFieldParams): string {
+ const value = optionalString({ record, key, label, maxLength });
+ if (!value) {
+ throw new Error(`${label} is required on every entry.`);
+ }
+ return value;
+}
+
+export const inputUtils = {
+ isRecord,
+ asRecords,
+ asStrings,
+ assertMaxLength,
+ assertUnique,
+ optionalString,
+ requiredString,
+};
+
+type AssertMaxLengthParams = { value: string | undefined; maxLength: number; label: string };
+type AssertUniqueParams = { values: string[]; label: string };
+type OptionalFieldParams = { record: Record; key: string; label?: string; maxLength?: number };
+type RequiredFieldParams = { record: Record; key: string; label: string; maxLength?: number };
diff --git a/packages/pieces/community/whatsapp/src/lib/common/props.ts b/packages/pieces/community/whatsapp/src/lib/common/props.ts
new file mode 100644
index 000000000000..e7ca76c2a0a1
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/common/props.ts
@@ -0,0 +1,131 @@
+import { Property } from '@activepieces/pieces-framework';
+
+const to = Property.ShortText({
+ displayName: 'To',
+ description: 'Recipient phone number in international format, digits only (for example 962791234567).',
+ required: true,
+});
+
+const replyToMessageId = Property.ShortText({
+ displayName: 'Reply To Message ID',
+ description: 'Optional WhatsApp message ID (wamid) to send this message as a reply to.',
+ required: false,
+});
+
+const messageId = Property.ShortText({
+ displayName: 'Message ID',
+ description: 'The WhatsApp message ID (wamid), for example from the New Incoming Message trigger.',
+ required: true,
+});
+
+const mediaId = Property.ShortText({
+ displayName: 'Media ID',
+ description: 'The media ID returned by Upload Media or received on an incoming media message.',
+ required: true,
+});
+
+const bodyText = Property.LongText({
+ displayName: 'Body',
+ description: 'Main message text.',
+ required: true,
+});
+
+const footerText = Property.ShortText({
+ displayName: 'Footer',
+ description: 'Optional footer text, max 60 characters.',
+ required: false,
+});
+
+const interactiveHeaderType = Property.StaticDropdown({
+ displayName: 'Header Type',
+ description: 'Optional header shown above the body: a short text line or a media item from a public URL.',
+ required: false,
+ defaultValue: 'none',
+ options: {
+ options: [
+ { label: 'None', value: 'none' },
+ { label: 'Text', value: 'text' },
+ { label: 'Image', value: 'image' },
+ { label: 'Video', value: 'video' },
+ { label: 'Document', value: 'document' },
+ ],
+ },
+});
+
+const interactiveHeaderText = Property.ShortText({
+ displayName: 'Header Text',
+ description: 'Used when Header Type is Text. Max 60 characters.',
+ required: false,
+});
+
+const interactiveHeaderMediaUrl = Property.ShortText({
+ displayName: 'Header Media URL',
+ description: 'Public URL of the header image, video or document. Used when Header Type is a media type.',
+ required: false,
+});
+
+const templateCategory = Property.StaticDropdown({
+ displayName: 'Category',
+ description: 'Meta reviews and prices the template by category. Utility for transactional updates, Marketing for promotions, Authentication for one-time passcodes.',
+ required: true,
+ options: {
+ options: [
+ { label: 'Marketing', value: 'MARKETING' },
+ { label: 'Utility', value: 'UTILITY' },
+ { label: 'Authentication', value: 'AUTHENTICATION' },
+ ],
+ },
+});
+
+const templateComponents = Property.Json({
+ displayName: 'Components',
+ description:
+ 'Template components array as documented by Meta. Write variables as [[1]] or [[name]] instead of double curly braces, which Activepieces reserves for its own expressions; they are converted before sending. Example: [{"type":"BODY","text":"Hello [[1]]","example":{"body_text":[["Sam"]]}}].',
+ required: true,
+});
+
+const businessVertical = Property.StaticDropdown({
+ displayName: 'Vertical',
+ description: 'Industry shown on the business profile.',
+ required: false,
+ options: {
+ options: [
+ 'OTHER',
+ 'AUTO',
+ 'BEAUTY',
+ 'APPAREL',
+ 'EDU',
+ 'ENTERTAIN',
+ 'EVENT_PLAN',
+ 'FINANCE',
+ 'GROCERY',
+ 'GOVT',
+ 'HOTEL',
+ 'HEALTH',
+ 'NONPROFIT',
+ 'PROF_SERVICES',
+ 'RETAIL',
+ 'TRAVEL',
+ 'RESTAURANT',
+ 'ALCOHOL',
+ 'ONLINE_GAMBLING',
+ 'PHYSICAL_GAMBLING',
+ 'OTC_DRUGS',
+ ].map((value) => ({ label: value, value })),
+ },
+});
+
+export const whatsappProps = {
+ to,
+ replyToMessageId,
+ messageId,
+ mediaId,
+ bodyText,
+ footerText,
+ interactiveHeaderType,
+ interactiveHeaderText,
+ interactiveHeaderMediaUrl,
+ templateCategory,
+ templateComponents,
+ businessVertical,
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/common/utils.ts b/packages/pieces/community/whatsapp/src/lib/common/utils.ts
index 658cd26d99bd..bec7f45d6d28 100644
--- a/packages/pieces/community/whatsapp/src/lib/common/utils.ts
+++ b/packages/pieces/community/whatsapp/src/lib/common/utils.ts
@@ -18,77 +18,7 @@ export const mediaTypeSupportsCaption = (type: string) =>
['image', 'video', 'document'].includes(type);
export const commonProps = {
- phone_number_id: Property.Dropdown({
- auth: whatsappAuth,
- displayName: 'From Phone Number',
- description: 'The business number the message is sent from.',
- refreshers: [],
- required: true,
- options: async ({ auth }) => {
- if (!auth) {
- return {
- placeholder: 'Please connect your account first',
- disabled: true,
- options: [],
- };
- }
-
- const authValue = auth.props;
-
- const options: DropdownOption[] = [];
-
- let hasMore = false;
- let cursor: string | undefined;
-
- do {
- const qs: QueryParams = {
- fields: 'verified_name,id,display_phone_number',
- limit: '100',
- };
- if (cursor) qs['after'] = cursor;
-
- const response = await httpClient.sendRequest({
- method: HttpMethod.GET,
- url: `https://graph.facebook.com/v20.0/${authValue.businessAccountId}/phone_numbers`,
- authentication: {
- type: AuthenticationType.BEARER_TOKEN,
- token: authValue.access_token,
- },
- queryParams: qs,
- });
-
- for (const phoneNumber of response.body.data ?? []) {
- options.push({
- label: `${phoneNumber.verified_name} (${phoneNumber.display_phone_number})`,
- value: phoneNumber.id,
- });
- }
-
- const nextPage = response.body.paging?.next;
- const nextCursor = response.body.paging?.cursors?.after;
-
- if (nextPage && nextCursor) {
- hasMore = true;
- cursor = nextCursor;
- } else {
- hasMore = false;
- }
- } while (hasMore);
-
- if (options.length === 0) {
- return {
- placeholder: 'No phone numbers found in this account',
- disabled: false,
- options: [],
- };
- }
-
- return {
- disabled: false,
- options,
- };
- },
- }),
+ phone_number_id: phoneNumberDropdown({ required: true }),
message_template_id: Property.Dropdown({
displayName: 'Template',
description: 'Only templates approved by WhatsApp are delivered.',
@@ -108,19 +38,18 @@ export const commonProps = {
const options: DropdownOption[] = [];
- let hasMore = false;
let cursor: string | undefined;
do {
const qs: QueryParams = {
fields: 'id,name,language',
- limit: '100',
+ limit: String(DROPDOWN_PAGE_SIZE),
};
if (cursor) qs['after'] = cursor;
- const response = await httpClient.sendRequest({
+ const response = await httpClient.sendRequest>({
method: HttpMethod.GET,
- url: `https://graph.facebook.com/v20.0/${authValue.businessAccountId}/message_templates`,
+ url: `${WHATSAPP_API_BASE}/${authValue.businessAccountId}/message_templates`,
authentication: {
type: AuthenticationType.BEARER_TOKEN,
token: authValue.access_token,
@@ -135,16 +64,8 @@ export const commonProps = {
});
}
- const nextPage = response.body.paging?.next;
- const nextCursor = response.body.paging?.cursors?.after;
-
- if (nextPage && nextCursor) {
- hasMore = true;
- cursor = nextCursor;
- } else {
- hasMore = false;
- }
- } while (hasMore);
+ cursor = nextCursor(response.body);
+ } while (cursor);
if (options.length === 0) {
return {
@@ -175,7 +96,7 @@ export const commonProps = {
const templateId = String(message_template_id);
const response = await httpClient.sendRequest({
- url: `https://graph.facebook.com/v20.0/${templateId}`,
+ url: `${WHATSAPP_API_BASE}/${templateId}`,
method: HttpMethod.GET,
authentication: {
type: AuthenticationType.BEARER_TOKEN,
@@ -243,35 +164,88 @@ export const commonProps = {
}),
};
-function escapeTemplatePlaceholders(text: string) {
- return text.replace(/{{(\d+)}}/g, '`{`{$1`}`}');
+
+function nextCursor(page: GraphPage): string | undefined {
+ return page.paging?.next ? page.paging.cursors?.after : undefined;
}
-type Paging = {
- next?: string;
- cursors?: {
- after?: string;
- };
-};
+export function phoneNumberDropdown({ required }: { required: R }) {
+ return Property.Dropdown({
+ auth: whatsappAuth,
+ displayName: 'From Phone Number',
+ description: 'The business number the message is sent from.',
+ refreshers: [],
+ required,
+ options: async ({ auth }) => {
+ if (!auth) {
+ return {
+ placeholder: 'Please connect your account first',
+ disabled: true,
+ options: [],
+ };
+ }
-type PhoneNumbersPage = {
- data?: {
- id: string;
- verified_name: string;
- display_phone_number: string;
- }[];
- paging?: Paging;
-};
+ const authValue = auth.props;
-type TemplatesPage = {
- data?: {
- id: string;
- name: string;
- language: string;
- }[];
- paging?: Paging;
+ const options: DropdownOption[] = [];
+
+ let cursor: string | undefined;
+
+ do {
+ const qs: QueryParams = {
+ fields: 'verified_name,id,display_phone_number',
+ limit: String(DROPDOWN_PAGE_SIZE),
+ };
+ if (cursor) qs['after'] = cursor;
+
+ const response = await httpClient.sendRequest>({
+ method: HttpMethod.GET,
+ url: `${WHATSAPP_API_BASE}/${authValue.businessAccountId}/phone_numbers`,
+ authentication: {
+ type: AuthenticationType.BEARER_TOKEN,
+ token: authValue.access_token,
+ },
+ queryParams: qs,
+ });
+
+ for (const phoneNumber of response.body.data ?? []) {
+ options.push({
+ label: `${phoneNumber.verified_name} (${phoneNumber.display_phone_number})`,
+ value: phoneNumber.id,
+ });
+ }
+
+ cursor = nextCursor(response.body);
+ } while (cursor);
+
+ if (options.length === 0) {
+ return {
+ placeholder: 'No phone numbers found in this account',
+ disabled: false,
+ options: [],
+ };
+ }
+
+ return {
+ disabled: false,
+ options,
+ };
+ },
+ });
+}
+
+export const WHATSAPP_API_BASE = 'https://graph.facebook.com/v23.0';
+
+const DROPDOWN_PAGE_SIZE = 100;
+
+type GraphPage = {
+ data?: T[];
+ paging?: { next?: string; cursors?: { after?: string } };
};
+type PhoneNumberRow = { id: string; verified_name: string; display_phone_number: string };
+type MessageTemplateRow = { id: string; name: string; language: string };
+
type TemplateDetails = {
components?: {
type: string;
@@ -284,3 +258,7 @@ type TemplateDetails = {
}[];
}[];
};
+
+function escapeTemplatePlaceholders(text: string) {
+ return text.replace(/{{(\d+)}}/g, '`{`{$1`}`}');
+}
diff --git a/packages/pieces/community/whatsapp/src/lib/common/webhook.ts b/packages/pieces/community/whatsapp/src/lib/common/webhook.ts
new file mode 100644
index 000000000000..53dc94c49f51
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/common/webhook.ts
@@ -0,0 +1,290 @@
+import { createHmac, timingSafeEqual } from 'crypto';
+import { Property, WebhookHandshakeStrategy, WebhookResponse } from '@activepieces/pieces-framework';
+import { inputUtils } from './inputs';
+
+const setupInstructions = Property.MarkDown({
+ value: `
+**Connect this trigger to your Meta app**
+
+1. **Publish this flow first.** Meta verifies the URL immediately, and an unpublished flow cannot answer.
+2. Open [developers.facebook.com](https://developers.facebook.com/apps), pick your app and go to **WhatsApp → Configuration**.
+3. Under **Webhook**, click **Edit** and paste this URL as the Callback URL:
+
+ \`{{webhookUrl}}\`
+
+4. Enter the same value you put in **Verify Token** below (any secret string), then click **Verify and save**.
+5. Click **Manage** next to Webhook fields and subscribe to **messages** (and **message_template_status_update** for template triggers).
+6. Make sure your app is subscribed to the WhatsApp Business Account itself. If events never arrive although the URL verified, call \`POST /{WABA_ID}/subscribed_apps\` with your access token; the dashboard does not show this state.
+
+To reject deliveries that are not signed by Meta, add your **App Secret** to the WhatsApp connection.
+
+Meta accepts **one callback URL per app**, so only the flow whose URL you paste receives events. To react to several event kinds, use one flow with this trigger and branch on the payload, or forward the payload to your other flows from a step.
+`,
+});
+
+const verifyToken = Property.ShortText({
+ displayName: 'Verify Token',
+ description: 'Optional. If set, Meta must send this exact token during webhook verification or the handshake is rejected.',
+ required: false,
+});
+
+const handshakeConfiguration = {
+ strategy: WebhookHandshakeStrategy.QUERY_PRESENT,
+ paramName: 'hub.challenge',
+} as const;
+
+function handleHandshake({ queryParams, expectedToken }: HandshakeParams): WebhookResponse {
+ const challenge = queryParams['hub.challenge'];
+ const sentToken = queryParams['hub.verify_token'];
+ if (expectedToken && sentToken !== expectedToken) {
+ return { status: 403, body: 'verify token mismatch', headers: { 'Content-Type': 'text/plain' } };
+ }
+ return { status: 200, body: challenge ?? '', headers: { 'Content-Type': 'text/plain' } };
+}
+
+function isSignedByMeta({ appSecret, headers, rawBody }: SignatureParams): boolean {
+ if (!appSecret) {
+ return true;
+ }
+ const header = headers['x-hub-signature-256'] ?? headers['X-Hub-Signature-256'];
+ const payload = rawBodyToBuffer(rawBody);
+ if (!header || !header.startsWith('sha256=') || payload === undefined) {
+ return false;
+ }
+ const expected = createHmac('sha256', appSecret).update(payload).digest('hex');
+ const received = header.slice('sha256='.length);
+ if (received.length !== expected.length) {
+ return false;
+ }
+ return timingSafeEqual(Buffer.from(received, 'utf8'), Buffer.from(expected, 'utf8'));
+}
+
+function rawBodyToBuffer(rawBody: unknown): Buffer | undefined {
+ if (Buffer.isBuffer(rawBody)) {
+ return rawBody;
+ }
+ if (typeof rawBody === 'string') {
+ return Buffer.from(rawBody, 'utf8');
+ }
+ return undefined;
+}
+
+function extractChanges({ body, field }: ExtractChangesParams): ChangeValue[] {
+ if (!isWebhookBody(body)) {
+ return [];
+ }
+ return (body.entry ?? []).flatMap((entry) =>
+ (entry.changes ?? [])
+ .filter((change) => change.field === field)
+ .map((change) => ({ ...change.value, waba_id: entry.id })),
+ );
+}
+
+function matchesPhoneNumber({ value, phoneNumberId }: MatchPhoneParams): boolean {
+ return !phoneNumberId || value.metadata?.phone_number_id === phoneNumberId;
+}
+
+function flattenIncomingMessage({ message, value }: FlattenMessageParams): FlatIncomingMessage {
+ const contact = value.contacts?.find((entry) => entry.wa_id === message.from) ?? value.contacts?.[0];
+ const media =
+ message.image ?? message.video ?? message.audio ?? message.document ?? message.sticker ?? undefined;
+ const interactiveReply = message.interactive?.button_reply ?? message.interactive?.list_reply;
+ return {
+ message_id: message.id,
+ from: message.from,
+ contact_name: contact?.profile?.name ?? null,
+ timestamp: message.timestamp,
+ received_at: toIso(message.timestamp),
+ type: message.type,
+ phone_number_id: value.metadata?.phone_number_id ?? null,
+ display_phone_number: value.metadata?.display_phone_number ?? null,
+ text: message.text?.body ?? null,
+ caption: media?.caption ?? null,
+ media_id: media?.id ?? null,
+ mime_type: media?.mime_type ?? null,
+ sha256: media?.sha256 ?? null,
+ filename: message.document?.filename ?? null,
+ latitude: message.location?.latitude ?? null,
+ longitude: message.location?.longitude ?? null,
+ location_name: message.location?.name ?? null,
+ location_address: message.location?.address ?? null,
+ interactive_type: message.interactive?.type ?? null,
+ interactive_reply_id: interactiveReply?.id ?? null,
+ interactive_reply_title: interactiveReply?.title ?? null,
+ interactive_reply_description: message.interactive?.list_reply?.description ?? null,
+ button_payload: message.button?.payload ?? null,
+ button_text: message.button?.text ?? null,
+ reaction_emoji: message.reaction?.emoji ?? null,
+ reaction_message_id: message.reaction?.message_id ?? null,
+ context_message_id: message.context?.id ?? null,
+ context_from: message.context?.from ?? null,
+ forwarded: message.context?.forwarded ?? false,
+ raw: message,
+ };
+}
+
+function flattenStatus({ status, value }: FlattenStatusParams): FlatStatus {
+ const error = status.errors?.[0];
+ return {
+ message_id: status.id,
+ status: status.status,
+ recipient_id: status.recipient_id,
+ timestamp: status.timestamp,
+ updated_at: toIso(status.timestamp),
+ phone_number_id: value.metadata?.phone_number_id ?? null,
+ display_phone_number: value.metadata?.display_phone_number ?? null,
+ conversation_id: status.conversation?.id ?? null,
+ conversation_origin: status.conversation?.origin?.type ?? null,
+ conversation_expires_at: toIso(status.conversation?.expiration_timestamp),
+ billable: status.pricing?.billable ?? null,
+ pricing_model: status.pricing?.pricing_model ?? null,
+ pricing_category: status.pricing?.category ?? null,
+ error_code: error?.code ?? null,
+ error_title: error?.title ?? null,
+ error_message: error?.message ?? null,
+ error_details: error?.error_data?.details ?? null,
+ raw: status,
+ };
+}
+
+function toIso(unixSeconds?: string): string | null {
+ if (!unixSeconds) return null;
+ const seconds = Number(unixSeconds);
+ return Number.isFinite(seconds) ? new Date(seconds * 1000).toISOString() : null;
+}
+
+function isWebhookBody(body: unknown): body is WebhookBody {
+ return inputUtils.isRecord(body) && body['object'] === 'whatsapp_business_account' && Array.isArray(body['entry']);
+}
+
+export const whatsappWebhook = {
+ setupInstructions,
+ verifyToken,
+ handshakeConfiguration,
+ handleHandshake,
+ isSignedByMeta,
+ extractChanges,
+ matchesPhoneNumber,
+ flattenIncomingMessage,
+ flattenStatus,
+};
+
+export type ChangeValue = {
+ waba_id: string;
+ messaging_product?: string;
+ metadata?: { display_phone_number?: string; phone_number_id?: string };
+ contacts?: { profile?: { name?: string }; wa_id: string }[];
+ messages?: IncomingMessage[];
+ statuses?: MessageStatus[];
+ event?: string;
+ message_template_id?: number | string;
+ message_template_name?: string;
+ message_template_language?: string;
+ reason?: string | null;
+ disable_info?: { disable_date?: string };
+ other_info?: { title?: string; description?: string };
+ rejection_info?: { reason?: string; recommendation?: string };
+};
+
+export type IncomingMessage = {
+ from: string;
+ id: string;
+ timestamp: string;
+ type: string;
+ text?: { body: string };
+ image?: MediaObject;
+ video?: MediaObject;
+ audio?: MediaObject;
+ document?: MediaObject & { filename?: string };
+ sticker?: MediaObject;
+ location?: { latitude: number; longitude: number; name?: string; address?: string };
+ contacts?: unknown[];
+ reaction?: { message_id: string; emoji?: string };
+ interactive?: {
+ type: string;
+ button_reply?: { id: string; title: string };
+ list_reply?: { id: string; title: string; description?: string };
+ };
+ button?: { payload: string; text: string };
+ context?: { from?: string; id?: string; forwarded?: boolean };
+ referral?: unknown;
+ errors?: unknown[];
+};
+
+export type MessageStatus = {
+ id: string;
+ status: string;
+ timestamp: string;
+ recipient_id: string;
+ conversation?: { id: string; origin?: { type: string }; expiration_timestamp?: string };
+ pricing?: { billable: boolean; pricing_model: string; category: string };
+ errors?: { code: number; title: string; message?: string; error_data?: { details?: string } }[];
+};
+
+export type FlatIncomingMessage = {
+ message_id: string;
+ from: string;
+ contact_name: string | null;
+ timestamp: string;
+ received_at: string | null;
+ type: string;
+ phone_number_id: string | null;
+ display_phone_number: string | null;
+ text: string | null;
+ caption: string | null;
+ media_id: string | null;
+ mime_type: string | null;
+ sha256: string | null;
+ filename: string | null;
+ latitude: number | null;
+ longitude: number | null;
+ location_name: string | null;
+ location_address: string | null;
+ interactive_type: string | null;
+ interactive_reply_id: string | null;
+ interactive_reply_title: string | null;
+ interactive_reply_description: string | null;
+ button_payload: string | null;
+ button_text: string | null;
+ reaction_emoji: string | null;
+ reaction_message_id: string | null;
+ context_message_id: string | null;
+ context_from: string | null;
+ forwarded: boolean;
+ raw: IncomingMessage;
+};
+
+export type FlatStatus = {
+ message_id: string;
+ status: string;
+ recipient_id: string;
+ timestamp: string;
+ updated_at: string | null;
+ phone_number_id: string | null;
+ display_phone_number: string | null;
+ conversation_id: string | null;
+ conversation_origin: string | null;
+ conversation_expires_at: string | null;
+ billable: boolean | null;
+ pricing_model: string | null;
+ pricing_category: string | null;
+ error_code: number | null;
+ error_title: string | null;
+ error_message: string | null;
+ error_details: string | null;
+ raw: MessageStatus;
+};
+
+type MediaObject = { id: string; mime_type: string; sha256: string; caption?: string };
+
+type WebhookBody = {
+ object: string;
+ entry?: { id: string; changes?: { field: string; value: Omit }[] }[];
+};
+
+type HandshakeParams = { queryParams: Record; expectedToken?: string };
+type SignatureParams = { appSecret: string | undefined; headers: Record; rawBody: unknown };
+type ExtractChangesParams = { body: unknown; field: string };
+type MatchPhoneParams = { value: ChangeValue; phoneNumberId?: string };
+type FlattenMessageParams = { message: IncomingMessage; value: ChangeValue };
+type FlattenStatusParams = { status: MessageStatus; value: ChangeValue };
diff --git a/packages/pieces/community/whatsapp/src/lib/output-schemas.ts b/packages/pieces/community/whatsapp/src/lib/output-schemas.ts
new file mode 100644
index 000000000000..1ae7ee9f6a23
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/output-schemas.ts
@@ -0,0 +1,226 @@
+import { OutputSchema } from '@activepieces/pieces-framework';
+
+const messageSendFields: OutputSchema['fields'] = [
+ { key: 'messaging_product', label: 'Messaging Product' },
+ {
+ key: 'contacts',
+ label: 'Contacts',
+ labelKey: 'wa_id',
+ listItems: [
+ { key: 'input', label: 'Input Number' },
+ { key: 'wa_id', label: 'WhatsApp ID' },
+ ],
+ },
+ {
+ key: 'messages',
+ label: 'Messages',
+ labelKey: 'id',
+ listItems: [
+ { key: 'id', label: 'Message ID' },
+ { key: 'message_status', label: 'Message Status' },
+ ],
+ },
+];
+
+const successFields: OutputSchema['fields'] = [{ key: 'success', label: 'Success', format: 'boolean' }];
+
+const mediaInfoFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Media ID' },
+ { key: 'mime_type', label: 'MIME Type' },
+ { key: 'sha256', label: 'SHA-256' },
+ { key: 'file_size', label: 'File Size', format: 'filesize' },
+];
+
+const templateFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Template ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'language', label: 'Language' },
+ { key: 'category', label: 'Category' },
+ { key: 'status', label: 'Status' },
+ { key: 'components', label: 'Components' },
+ {
+ key: 'quality_score',
+ label: 'Quality Score',
+ children: [
+ { key: 'score', label: 'Score' },
+ { key: 'date', label: 'Scored At' },
+ ],
+ },
+];
+
+const phoneNumberFields: OutputSchema['fields'] = [
+ { key: 'id', label: 'Phone Number ID' },
+ { key: 'verified_name', label: 'Verified Name' },
+ { key: 'display_phone_number', label: 'Display Phone Number' },
+ { key: 'quality_rating', label: 'Quality Rating' },
+ { key: 'code_verification_status', label: 'Code Verification Status' },
+ { key: 'platform_type', label: 'Platform Type' },
+];
+
+const businessProfileFields: OutputSchema['fields'] = [
+ { key: 'messaging_product', label: 'Messaging Product' },
+ { key: 'about', label: 'About' },
+ { key: 'address', label: 'Address' },
+ { key: 'description', label: 'Description' },
+ { key: 'email', label: 'Email', format: 'email' },
+ { key: 'profile_picture_url', label: 'Profile Picture', format: 'image' },
+ { key: 'websites', label: 'Websites' },
+ { key: 'vertical', label: 'Vertical' },
+];
+
+const incomingMessageCommonFields: OutputSchema['fields'] = [
+ { key: 'message_id', label: 'Message ID' },
+ { key: 'from', label: 'From' },
+ { key: 'contact_name', label: 'Contact Name' },
+ { key: 'timestamp', label: 'Timestamp' },
+ { key: 'received_at', label: 'Received At', format: 'datetime' },
+ { key: 'type', label: 'Type' },
+ { key: 'phone_number_id', label: 'Phone Number ID' },
+ { key: 'display_phone_number', label: 'Display Phone Number' },
+];
+
+const incomingMessageContextFields: OutputSchema['fields'] = [
+ { key: 'context_message_id', label: 'Replied-To Message ID' },
+ { key: 'context_from', label: 'Replied-To Sender' },
+ { key: 'forwarded', label: 'Forwarded', format: 'boolean' },
+ { key: 'raw', label: 'Raw Message' },
+];
+
+export const messageSendOutputSchema: OutputSchema = { fields: messageSendFields };
+
+export const markMessageAsReadOutputSchema: OutputSchema = { fields: successFields };
+
+export const uploadMediaOutputSchema: OutputSchema = {
+ fields: [{ key: 'id', label: 'Media ID' }],
+};
+
+export const getMediaUrlOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'messaging_product', label: 'Messaging Product' },
+ { key: 'url', label: 'Download URL', format: 'url' },
+ ...mediaInfoFields,
+ ],
+};
+
+export const downloadMediaOutputSchema: OutputSchema = {
+ fields: [{ key: 'file', label: 'File', format: 'url' }, ...mediaInfoFields],
+};
+
+export const deleteMediaOutputSchema: OutputSchema = { fields: successFields };
+
+export const listMessageTemplatesOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'templates', label: 'Templates', listItems: templateFields, labelKey: 'name' },
+ { key: 'count', label: 'Count', format: 'number' },
+ ],
+};
+
+export const createMessageTemplateOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'id', label: 'Template ID' },
+ { key: 'status', label: 'Status' },
+ { key: 'category', label: 'Category' },
+ ],
+};
+
+export const editMessageTemplateOutputSchema: OutputSchema = {
+ fields: [
+ ...successFields,
+ { key: 'id', label: 'Template ID' },
+ { key: 'name', label: 'Name' },
+ { key: 'category', label: 'Category' },
+ ],
+};
+
+export const deleteMessageTemplateOutputSchema: OutputSchema = { fields: successFields };
+
+export const listPhoneNumbersOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'phone_numbers', label: 'Phone Numbers', listItems: phoneNumberFields, labelKey: 'display_phone_number' },
+ { key: 'count', label: 'Count', format: 'number' },
+ ],
+};
+
+export const getPhoneNumberOutputSchema: OutputSchema = {
+ fields: [
+ ...phoneNumberFields,
+ { key: 'name_status', label: 'Name Status' },
+ { key: 'status', label: 'Status' },
+ { key: 'throughput', label: 'Throughput', children: [{ key: 'level', label: 'Level' }] },
+ ],
+};
+
+export const getBusinessProfileOutputSchema: OutputSchema = { fields: businessProfileFields };
+
+export const updateBusinessProfileOutputSchema: OutputSchema = { fields: successFields };
+
+export const newIncomingMessageOutputSchema: OutputSchema = {
+ fields: [
+ ...incomingMessageCommonFields,
+ { key: 'text', label: 'Text' },
+ { key: 'caption', label: 'Caption' },
+ { key: 'media_id', label: 'Media ID' },
+ { key: 'mime_type', label: 'MIME Type' },
+ { key: 'sha256', label: 'SHA-256' },
+ { key: 'filename', label: 'Filename' },
+ { key: 'latitude', label: 'Latitude', format: 'number' },
+ { key: 'longitude', label: 'Longitude', format: 'number' },
+ { key: 'location_name', label: 'Location Name' },
+ { key: 'location_address', label: 'Location Address' },
+ { key: 'interactive_type', label: 'Interactive Type' },
+ { key: 'interactive_reply_id', label: 'Selected Option ID' },
+ { key: 'interactive_reply_title', label: 'Selected Option Title' },
+ { key: 'interactive_reply_description', label: 'Selected Option Description' },
+ { key: 'button_payload', label: 'Template Button Payload' },
+ { key: 'button_text', label: 'Template Button Text' },
+ ...incomingMessageContextFields,
+ ],
+};
+
+export const newMessageReactionOutputSchema: OutputSchema = {
+ fields: [
+ ...incomingMessageCommonFields,
+ { key: 'reaction_emoji', label: 'Emoji' },
+ { key: 'reaction_message_id', label: 'Reacted-To Message ID' },
+ ...incomingMessageContextFields,
+ ],
+};
+
+export const messageStatusUpdatedOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'message_id', label: 'Message ID' },
+ { key: 'status', label: 'Status' },
+ { key: 'recipient_id', label: 'Recipient' },
+ { key: 'timestamp', label: 'Timestamp' },
+ { key: 'updated_at', label: 'Updated At', format: 'datetime' },
+ { key: 'phone_number_id', label: 'Phone Number ID' },
+ { key: 'display_phone_number', label: 'Display Phone Number' },
+ { key: 'conversation_id', label: 'Conversation ID' },
+ { key: 'conversation_origin', label: 'Conversation Origin' },
+ { key: 'conversation_expires_at', label: 'Conversation Expires At', format: 'datetime' },
+ { key: 'billable', label: 'Billable', format: 'boolean' },
+ { key: 'pricing_model', label: 'Pricing Model' },
+ { key: 'pricing_category', label: 'Pricing Category' },
+ { key: 'error_code', label: 'Error Code', format: 'number' },
+ { key: 'error_title', label: 'Error Title' },
+ { key: 'error_message', label: 'Error Message' },
+ { key: 'error_details', label: 'Error Details' },
+ { key: 'raw', label: 'Raw Status' },
+ ],
+};
+
+export const templateStatusUpdatedOutputSchema: OutputSchema = {
+ fields: [
+ { key: 'waba_id', label: 'Business Account ID' },
+ { key: 'event', label: 'Event' },
+ { key: 'message_template_id', label: 'Template ID' },
+ { key: 'message_template_name', label: 'Template Name' },
+ { key: 'message_template_language', label: 'Template Language' },
+ { key: 'reason', label: 'Reason' },
+ { key: 'disable_date', label: 'Disabled At' },
+ { key: 'other_info_title', label: 'Info Title' },
+ { key: 'other_info_description', label: 'Info Description' },
+ { key: 'rejection_reason', label: 'Rejection Reason' },
+ { key: 'rejection_recommendation', label: 'Rejection Recommendation' },
+ ],
+};
diff --git a/packages/pieces/community/whatsapp/src/lib/triggers/message-status-updated.ts b/packages/pieces/community/whatsapp/src/lib/triggers/message-status-updated.ts
new file mode 100644
index 000000000000..a85f70342250
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/triggers/message-status-updated.ts
@@ -0,0 +1,93 @@
+import { createTrigger, Property, TriggerStrategy } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { phoneNumberDropdown } from '../common/utils';
+import { whatsappWebhook } from '../common/webhook';
+import { inputUtils } from '../common/inputs';
+import { messageStatusUpdatedOutputSchema } from '../output-schemas';
+
+export const messageStatusUpdated = createTrigger({
+ auth: whatsappAuth,
+ name: 'message_status_updated',
+ outputSchema: messageStatusUpdatedOutputSchema,
+ classification: 'READ',
+ displayName: 'Message Status Updated',
+ description: 'Fires when a message you sent is delivered, read, or fails.',
+ aiMetadata: {
+ description:
+ 'Fires once per delivery-state change of a message the business sent: sent, delivered, read or failed. The payload has the message id to match against your send step, the new status, conversation and pricing details, and the error code and message when delivery failed. Filter by status to react only to failures or read receipts.',
+ },
+ type: TriggerStrategy.WEBHOOK,
+ props: {
+ instructions: whatsappWebhook.setupInstructions,
+ verify_token: whatsappWebhook.verifyToken,
+ phone_number_id: phoneNumberDropdown({ required: false }),
+ status: Property.StaticMultiSelectDropdown({
+ displayName: 'Statuses',
+ description: 'Only fire for these statuses. Leave empty for all.',
+ required: false,
+ options: {
+ options: [
+ { label: 'Sent', value: 'sent' },
+ { label: 'Delivered', value: 'delivered' },
+ { label: 'Read', value: 'read' },
+ { label: 'Failed', value: 'failed' },
+ ],
+ },
+ }),
+ },
+ sampleData: {
+ message_id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAERgSRTAwNUQyMjA1MTA2QUIxODREAA==',
+ status: 'delivered',
+ recipient_id: '962782550213',
+ timestamp: '1789520050',
+ updated_at: '2026-09-16T00:54:10.000Z',
+ phone_number_id: '1285944454608901',
+ display_phone_number: '15551394669',
+ conversation_id: 'a8b1c2d3e4f5061728394a5b6c7d8e9f',
+ conversation_origin: 'utility',
+ conversation_expires_at: '2026-09-17T00:53:20.000Z',
+ billable: true,
+ pricing_model: 'CBP',
+ pricing_category: 'utility',
+ error_code: null,
+ error_title: null,
+ error_message: null,
+ error_details: null,
+ raw: {
+ id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAERgSRTAwNUQyMjA1MTA2QUIxODREAA==',
+ status: 'delivered',
+ timestamp: '1789520050',
+ recipient_id: '962782550213',
+ conversation: { id: 'a8b1c2d3e4f5061728394a5b6c7d8e9f', origin: { type: 'utility' }, expiration_timestamp: '1789606400' },
+ pricing: { billable: true, pricing_model: 'CBP', category: 'utility' },
+ },
+ },
+ handshakeConfiguration: whatsappWebhook.handshakeConfiguration,
+ async onHandshake(context) {
+ return whatsappWebhook.handleHandshake({
+ queryParams: context.payload.queryParams,
+ expectedToken: context.propsValue.verify_token,
+ });
+ },
+ async onEnable() {
+ return;
+ },
+ async onDisable() {
+ return;
+ },
+ async run(context) {
+ if (!whatsappWebhook.isSignedByMeta({ appSecret: context.auth.props.app_secret, headers: context.payload.headers, rawBody: context.payload.rawBody })) {
+ return [];
+ }
+ const phoneNumberId = context.propsValue.phone_number_id;
+ const wantedStatuses = inputUtils.asStrings(context.propsValue.status);
+ return whatsappWebhook
+ .extractChanges({ body: context.payload.body, field: 'messages' })
+ .filter((value) => whatsappWebhook.matchesPhoneNumber({ value, phoneNumberId }))
+ .flatMap((value) =>
+ (value.statuses ?? [])
+ .filter((status) => wantedStatuses.length === 0 || wantedStatuses.includes(status.status))
+ .map((status) => whatsappWebhook.flattenStatus({ status, value })),
+ );
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/triggers/new-incoming-message.ts b/packages/pieces/community/whatsapp/src/lib/triggers/new-incoming-message.ts
new file mode 100644
index 000000000000..01aaf0d9e577
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/triggers/new-incoming-message.ts
@@ -0,0 +1,89 @@
+import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { phoneNumberDropdown } from '../common/utils';
+import { whatsappWebhook } from '../common/webhook';
+import { newIncomingMessageOutputSchema } from '../output-schemas';
+
+export const newIncomingMessage = createTrigger({
+ auth: whatsappAuth,
+ name: 'new_incoming_message',
+ outputSchema: newIncomingMessageOutputSchema,
+ classification: 'READ',
+ displayName: 'New Incoming Message',
+ description: 'Fires when a customer sends your business number a message.',
+ aiMetadata: {
+ description:
+ 'Fires once per message a WhatsApp user sends to the business number: text, media, location, contacts, interactive button or list replies. Reactions are excluded and have their own trigger. The payload is flattened with the sender, message id, type-specific fields and a media id you can pass to Download Media; receiving a message opens the 24-hour window for free-form replies.',
+ },
+ type: TriggerStrategy.WEBHOOK,
+ props: {
+ instructions: whatsappWebhook.setupInstructions,
+ verify_token: whatsappWebhook.verifyToken,
+ phone_number_id: phoneNumberDropdown({ required: false }),
+ },
+ sampleData: {
+ message_id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAEhgUM0E3RjM1QjQ1RTk0RkY1RkQ4QjcA',
+ from: '962782550213',
+ contact_name: 'Odai',
+ timestamp: '1789520000',
+ received_at: '2026-09-16T00:53:20.000Z',
+ type: 'text',
+ phone_number_id: '1285944454608901',
+ display_phone_number: '15551394669',
+ text: 'Hello, I would like to know my order status.',
+ caption: null,
+ media_id: null,
+ mime_type: null,
+ sha256: null,
+ filename: null,
+ latitude: null,
+ longitude: null,
+ location_name: null,
+ location_address: null,
+ interactive_type: null,
+ interactive_reply_id: null,
+ interactive_reply_title: null,
+ interactive_reply_description: null,
+ button_payload: null,
+ button_text: null,
+ reaction_emoji: null,
+ reaction_message_id: null,
+ context_message_id: null,
+ context_from: null,
+ forwarded: false,
+ raw: {
+ from: '962782550213',
+ id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAEhgUM0E3RjM1QjQ1RTk0RkY1RkQ4QjcA',
+ timestamp: '1789520000',
+ type: 'text',
+ text: { body: 'Hello, I would like to know my order status.' },
+ },
+ },
+ handshakeConfiguration: whatsappWebhook.handshakeConfiguration,
+ async onHandshake(context) {
+ return whatsappWebhook.handleHandshake({
+ queryParams: context.payload.queryParams,
+ expectedToken: context.propsValue.verify_token,
+ });
+ },
+ async onEnable() {
+ return;
+ },
+ async onDisable() {
+ return;
+ },
+ async run(context) {
+ if (!whatsappWebhook.isSignedByMeta({ appSecret: context.auth.props.app_secret, headers: context.payload.headers, rawBody: context.payload.rawBody })) {
+ return [];
+ }
+ const phoneNumberId = context.propsValue.phone_number_id;
+ return whatsappWebhook
+ .extractChanges({ body: context.payload.body, field: 'messages' })
+ .filter((value) => whatsappWebhook.matchesPhoneNumber({ value, phoneNumberId }))
+ .flatMap((value) =>
+ (value.messages ?? [])
+ .filter((message) => message.type !== 'reaction')
+ .map((message) => whatsappWebhook.flattenIncomingMessage({ message, value })),
+ );
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/triggers/new-message-reaction.ts b/packages/pieces/community/whatsapp/src/lib/triggers/new-message-reaction.ts
new file mode 100644
index 000000000000..dc2aef68f7de
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/triggers/new-message-reaction.ts
@@ -0,0 +1,91 @@
+import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { phoneNumberDropdown } from '../common/utils';
+import { whatsappWebhook } from '../common/webhook';
+import { newMessageReactionOutputSchema } from '../output-schemas';
+
+export const newMessageReaction = createTrigger({
+ auth: whatsappAuth,
+ name: 'new_message_reaction',
+ outputSchema: newMessageReactionOutputSchema,
+ classification: 'READ',
+ displayName: 'New Message Reaction',
+ description: 'Fires when a customer reacts to one of your messages with an emoji, or removes a reaction.',
+ aiMetadata: {
+ description:
+ 'Fires once each time a WhatsApp user adds or removes an emoji reaction on a message from the business number. The payload carries the reacted-to message id and the emoji; an empty emoji means the reaction was removed. Use New Incoming Message for every other message type.',
+ },
+ type: TriggerStrategy.WEBHOOK,
+ props: {
+ instructions: whatsappWebhook.setupInstructions,
+ verify_token: whatsappWebhook.verifyToken,
+ phone_number_id: phoneNumberDropdown({ required: false }),
+ },
+ sampleData: {
+ message_id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAEhgUM0E3RjM1QjQ1RTk0RkY1RkQ4QjcA',
+ from: '962782550213',
+ contact_name: 'Odai',
+ timestamp: '1789520100',
+ received_at: '2026-09-16T00:55:00.000Z',
+ type: 'reaction',
+ phone_number_id: '1285944454608901',
+ display_phone_number: '15551394669',
+ reaction_emoji: '👍',
+ reaction_message_id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAERgSRTAwNUQyMjA1MTA2QUIxODREAA==',
+ context_message_id: null,
+ context_from: null,
+ forwarded: false,
+ raw: {
+ from: '962782550213',
+ id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAEhgUM0E3RjM1QjQ1RTk0RkY1RkQ4QjcA',
+ timestamp: '1789520100',
+ type: 'reaction',
+ reaction: { message_id: 'wamid.HBgMOTYyNzgyNTUwMjEzFQIAERgSRTAwNUQyMjA1MTA2QUIxODREAA==', emoji: '👍' },
+ },
+ },
+ handshakeConfiguration: whatsappWebhook.handshakeConfiguration,
+ async onHandshake(context) {
+ return whatsappWebhook.handleHandshake({
+ queryParams: context.payload.queryParams,
+ expectedToken: context.propsValue.verify_token,
+ });
+ },
+ async onEnable() {
+ return;
+ },
+ async onDisable() {
+ return;
+ },
+ async run(context) {
+ if (!whatsappWebhook.isSignedByMeta({ appSecret: context.auth.props.app_secret, headers: context.payload.headers, rawBody: context.payload.rawBody })) {
+ return [];
+ }
+ const phoneNumberId = context.propsValue.phone_number_id;
+ return whatsappWebhook
+ .extractChanges({ body: context.payload.body, field: 'messages' })
+ .filter((value) => whatsappWebhook.matchesPhoneNumber({ value, phoneNumberId }))
+ .flatMap((value) =>
+ (value.messages ?? [])
+ .filter((message) => message.type === 'reaction')
+ .map((message) => {
+ const flat = whatsappWebhook.flattenIncomingMessage({ message, value });
+ return {
+ message_id: flat.message_id,
+ from: flat.from,
+ contact_name: flat.contact_name,
+ timestamp: flat.timestamp,
+ received_at: flat.received_at,
+ type: flat.type,
+ phone_number_id: flat.phone_number_id,
+ display_phone_number: flat.display_phone_number,
+ reaction_emoji: flat.reaction_emoji,
+ reaction_message_id: flat.reaction_message_id,
+ context_message_id: flat.context_message_id,
+ context_from: flat.context_from,
+ forwarded: flat.forwarded,
+ raw: flat.raw,
+ };
+ }),
+ );
+ },
+});
diff --git a/packages/pieces/community/whatsapp/src/lib/triggers/template-status-updated.ts b/packages/pieces/community/whatsapp/src/lib/triggers/template-status-updated.ts
new file mode 100644
index 000000000000..f3e39c7235e3
--- /dev/null
+++ b/packages/pieces/community/whatsapp/src/lib/triggers/template-status-updated.ts
@@ -0,0 +1,68 @@
+import { createTrigger, TriggerStrategy } from '@activepieces/pieces-framework';
+import { whatsappAuth } from '../auth';
+import { whatsappWebhook } from '../common/webhook';
+import { templateStatusUpdatedOutputSchema } from '../output-schemas';
+
+export const templateStatusUpdated = createTrigger({
+ auth: whatsappAuth,
+ name: 'template_status_updated',
+ outputSchema: templateStatusUpdatedOutputSchema,
+ classification: 'READ',
+ displayName: 'Template Status Updated',
+ description: 'Fires when Meta approves, rejects, pauses or otherwise changes a message template.',
+ aiMetadata: {
+ description:
+ 'Fires once per status change on a WhatsApp message template: APPROVED, REJECTED, PAUSED, DISABLED, PENDING and related events. The payload names the template, its language, the new event and, for rejections, the reason and Meta\'s recommendation. Subscribe your app to the message_template_status_update webhook field for this to fire.',
+ },
+ type: TriggerStrategy.WEBHOOK,
+ props: {
+ instructions: whatsappWebhook.setupInstructions,
+ verify_token: whatsappWebhook.verifyToken,
+ },
+ sampleData: {
+ waba_id: '102290129340398',
+ event: 'APPROVED',
+ message_template_id: '959304846505478',
+ message_template_name: 'hello_world',
+ message_template_language: 'en_US',
+ reason: null,
+ disable_date: null,
+ other_info_title: null,
+ other_info_description: null,
+ rejection_reason: null,
+ rejection_recommendation: null,
+ },
+ handshakeConfiguration: whatsappWebhook.handshakeConfiguration,
+ async onHandshake(context) {
+ return whatsappWebhook.handleHandshake({
+ queryParams: context.payload.queryParams,
+ expectedToken: context.propsValue.verify_token,
+ });
+ },
+ async onEnable() {
+ return;
+ },
+ async onDisable() {
+ return;
+ },
+ async run(context) {
+ if (!whatsappWebhook.isSignedByMeta({ appSecret: context.auth.props.app_secret, headers: context.payload.headers, rawBody: context.payload.rawBody })) {
+ return [];
+ }
+ return whatsappWebhook
+ .extractChanges({ body: context.payload.body, field: 'message_template_status_update' })
+ .map((value) => ({
+ waba_id: value.waba_id,
+ event: value.event ?? null,
+ message_template_id: value.message_template_id != null ? String(value.message_template_id) : null,
+ message_template_name: value.message_template_name ?? null,
+ message_template_language: value.message_template_language ?? null,
+ reason: value.reason ?? null,
+ disable_date: value.disable_info?.disable_date ?? null,
+ other_info_title: value.other_info?.title ?? null,
+ other_info_description: value.other_info?.description ?? null,
+ rejection_reason: value.rejection_info?.reason ?? null,
+ rejection_recommendation: value.rejection_info?.recommendation ?? null,
+ }));
+ },
+});
diff --git a/packages/pieces/community/whatsapp/test/actions.test.ts b/packages/pieces/community/whatsapp/test/actions.test.ts
new file mode 100644
index 000000000000..febf72176a5a
--- /dev/null
+++ b/packages/pieces/community/whatsapp/test/actions.test.ts
@@ -0,0 +1,402 @@
+///
+
+import { httpClient, HttpMethod, HttpRequest, HttpResponse } from '@activepieces/pieces-common';
+import { createMockActionContext } from '@activepieces/pieces-framework';
+import { sendInteractiveButtons } from '../src/lib/actions/send-interactive-buttons';
+import { sendInteractiveList } from '../src/lib/actions/send-interactive-list';
+import { sendInteractiveCtaUrl } from '../src/lib/actions/send-interactive-cta-url';
+import { sendLocation } from '../src/lib/actions/send-location';
+import { sendContact } from '../src/lib/actions/send-contact';
+import { sendReaction } from '../src/lib/actions/send-reaction';
+import { markMessageAsRead } from '../src/lib/actions/mark-message-as-read';
+import { uploadMedia } from '../src/lib/actions/upload-media';
+import { createMessageTemplate } from '../src/lib/actions/create-message-template';
+import { deleteMessageTemplate } from '../src/lib/actions/delete-message-template';
+import { updateBusinessProfile } from '../src/lib/actions/update-business-profile';
+import { getBusinessProfile } from '../src/lib/actions/get-business-profile';
+import { listMessageTemplates } from '../src/lib/actions/list-message-templates';
+import { listPhoneNumbers } from '../src/lib/actions/list-phone-numbers';
+import { sendMessage } from '../src/lib/actions/send-message';
+import { sendMedia } from '../src/lib/actions/send-media';
+import { sendTemplate } from '../src/lib/actions/send-template';
+import { inputUtils } from '../src/lib/common/inputs';
+
+const AUTH = { type: 'CUSTOM_AUTH', props: { access_token: 'tok', businessAccountId: 'WABA' } };
+const SEND_RESPONSE = { messaging_product: 'whatsapp', contacts: [{ input: '1', wa_id: '1' }], messages: [{ id: 'wamid.NEW' }] };
+const BASE = { phone_number_id: 'PN', to: '962782550213' };
+
+const requests: HttpRequest[] = [];
+
+function objectAt({ value, path }: ObjectAtParams): Record {
+ const found = path.split('.').reduce((current, segment) => {
+ if (inputUtils.isRecord(current)) return current[segment];
+ if (Array.isArray(current)) return current[Number(segment)];
+ return undefined;
+ }, value);
+ if (!inputUtils.isRecord(found)) {
+ throw new Error(`expected an object at ${path}`);
+ }
+ return found;
+}
+
+function bufferBody(value: unknown): Buffer {
+ if (!Buffer.isBuffer(value)) {
+ throw new Error('expected a Buffer request body');
+ }
+ return value;
+}
+
+function context>(propsValue: T) {
+ return { ...createMockActionContext({ propsValue }), auth: AUTH };
+}
+
+function respondWith(...bodies: unknown[]) {
+ let call = 0;
+ vi.spyOn(httpClient, 'sendRequest').mockImplementation(async (request: HttpRequest): Promise => {
+ requests.push(request);
+ const body = bodies[Math.min(call, bodies.length - 1)];
+ call += 1;
+ return { status: 200, headers: {}, body };
+ });
+}
+
+beforeEach(() => {
+ requests.length = 0;
+ respondWith(SEND_RESPONSE);
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('send actions return the bare API payload', () => {
+ test('Send Message posts a text payload and returns response.body, not the HTTP envelope', async () => {
+ const out = await sendMessage.run(context({ ...BASE, text: 'hello' }));
+ expect(out).toEqual(SEND_RESPONSE);
+ expect(out).not.toHaveProperty('status');
+ expect(requests[0].url).toBe('https://graph.facebook.com/v23.0/PN/messages');
+ expect(requests[0].body).toEqual({ messaging_product: 'whatsapp', recipient_type: 'individual', to: '962782550213', type: 'text', text: { body: 'hello' } });
+ });
+
+ test('Send Media only attaches caption where the type allows it, and filename only for documents', async () => {
+ await sendMedia.run(context({ ...BASE, type: 'audio', media: 'https://x/a.mp3', caption: 'ignored', filename: 'ignored' }));
+ expect(requests[0].body).toMatchObject({ type: 'audio', audio: { link: 'https://x/a.mp3' } });
+ expect(requests[0].body).not.toMatchObject({ audio: { caption: 'ignored' } });
+
+ await sendMedia.run(context({ ...BASE, type: 'document', media: 'https://x/a.pdf', caption: 'see attached', filename: 'a.pdf' }));
+ expect(requests[1].body).toMatchObject({ type: 'document', document: { link: 'https://x/a.pdf', caption: 'see attached', filename: 'a.pdf' } });
+
+ await sendMedia.run(context({ ...BASE, type: 'image', media: 'https://x/a.png', filename: 'nope.png' }));
+ expect(requests[2].body).toMatchObject({ image: { link: 'https://x/a.png' } });
+ expect(requests[2].body).not.toMatchObject({ image: { filename: 'nope.png' } });
+ });
+});
+
+describe('Send Template (agent twin)', () => {
+ test('sends name + language only when there are no parameters', async () => {
+ await sendTemplate.run(context({ ...BASE, template_name: 'hello_world', language_code: 'en_US' }));
+ expect(requests[0].body).toEqual({
+ messaging_product: 'whatsapp',
+ recipient_type: 'individual',
+ to: '962782550213',
+ type: 'template',
+ template: { name: 'hello_world', language: { code: 'en_US' } },
+ });
+ });
+
+ test('builds header, body and url-button components from positional lists, accepting JSON strings', async () => {
+ await sendTemplate.run(
+ context({
+ ...BASE,
+ template_name: 'order_update',
+ language_code: 'en_US',
+ header_parameters: ['Sam'],
+ body_parameters: '["A1","tomorrow"]',
+ button_url_parameters: ['track/A1', ''],
+ reply_to_message_id: 'wamid.R',
+ }),
+ );
+ expect(requests[0].body).toMatchObject({
+ context: { message_id: 'wamid.R' },
+ template: {
+ name: 'order_update',
+ language: { code: 'en_US' },
+ components: [
+ { type: 'header', parameters: [{ type: 'text', text: 'Sam' }] },
+ { type: 'body', parameters: [{ type: 'text', text: 'A1' }, { type: 'text', text: 'tomorrow' }] },
+ { type: 'button', sub_type: 'url', index: 0, parameters: [{ type: 'text', text: 'track/A1' }] },
+ ],
+ },
+ });
+ });
+});
+
+describe('Send Interactive Buttons', () => {
+ test('a selected header with no content is rejected instead of sent without a header', async () => {
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body_text: 'Pick', header_type: 'image', buttons: [{ id: 'a', title: 'A' }] }))).rejects.toThrow('Header Media URL is required');
+ expect(requests).toHaveLength(0);
+ });
+ const buttons = [{ id: 'yes', title: 'Yes' }, { id: 'no', title: 'No' }];
+
+ test('happy path builds the interactive button payload with optional header and footer', async () => {
+ const out = await sendInteractiveButtons.run(
+ context({ ...BASE, body: 'Pick', buttons, header_type: 'text', header_text: 'Head', footer: 'Foot', reply_to_message_id: 'wamid.R' }),
+ );
+ expect(out).toEqual(SEND_RESPONSE);
+ expect(requests[0].body).toEqual({
+ messaging_product: 'whatsapp',
+ recipient_type: 'individual',
+ to: '962782550213',
+ context: { message_id: 'wamid.R' },
+ type: 'interactive',
+ interactive: {
+ type: 'button',
+ header: { type: 'text', text: 'Head' },
+ body: { text: 'Pick' },
+ footer: { text: 'Foot' },
+ action: { buttons: [{ type: 'reply', reply: { id: 'yes', title: 'Yes' } }, { type: 'reply', reply: { id: 'no', title: 'No' } }] },
+ },
+ });
+ });
+
+ test('omits header and footer when not provided and accepts the array as a JSON string', async () => {
+ await sendInteractiveButtons.run(context({ ...BASE, body: 'Pick', buttons: JSON.stringify(buttons), header_type: 'none' }));
+ const interactive = objectAt({ value: requests[0].body, path: 'interactive' });
+ expect(interactive).not.toHaveProperty('header');
+ expect(interactive).not.toHaveProperty('footer');
+ expect(interactive.action).toEqual({ buttons: [{ type: 'reply', reply: { id: 'yes', title: 'Yes' } }, { type: 'reply', reply: { id: 'no', title: 'No' } }] });
+ });
+
+ test('rejects zero or more than three buttons before calling the API', async () => {
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body: 'x', buttons: [] }))).rejects.toThrow('between 1 and 3');
+ await expect(
+ sendInteractiveButtons.run(context({ ...BASE, body: 'x', buttons: [1, 2, 3, 4].map((n) => ({ id: `${n}`, title: `T${n}` })) })),
+ ).rejects.toThrow('between 1 and 3');
+ expect(requests).toHaveLength(0);
+ });
+
+ test('rejects duplicate titles, duplicate ids, missing ids, over-long titles and over-long body', async () => {
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body: 'x', buttons: [{ id: 'a', title: 'Same' }, { id: 'b', title: 'Same' }] }))).rejects.toThrow(
+ 'Button titles must be unique.',
+ );
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body: 'x', buttons: [{ id: 'a', title: 'One' }, { id: 'a', title: 'Two' }] }))).rejects.toThrow(
+ 'Button IDs must be unique.',
+ );
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body: 'x', buttons: [{ title: 'No id' }] }))).rejects.toThrow('Button ID is required');
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body: 'x', buttons: [{ id: 'a', title: 'x'.repeat(21) }] }))).rejects.toThrow(
+ 'Button title must be at most 20 characters (got 21).',
+ );
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body: 'x'.repeat(1025), buttons }))).rejects.toThrow('Body must be at most 1024');
+ await expect(sendInteractiveButtons.run(context({ ...BASE, body: 'ok', buttons, footer: 'f'.repeat(61) }))).rejects.toThrow('Footer must be at most 60');
+ expect(requests).toHaveLength(0);
+ });
+});
+
+describe('Send Interactive List', () => {
+ const rows = [{ id: 'r1', title: 'Row one', description: 'first' }, { id: 'r2', title: 'Row two' }];
+ const base = { ...BASE, body: 'Choose', button_text: 'Open', section_title: 'Options', rows };
+
+ test('happy path builds one section with rows and drops empty descriptions', async () => {
+ await sendInteractiveList.run(context({ ...base, header_text: 'Menu' }));
+ expect(requests[0].body).toMatchObject({
+ type: 'interactive',
+ interactive: {
+ type: 'list',
+ header: { type: 'text', text: 'Menu' },
+ body: { text: 'Choose' },
+ action: { button: 'Open', sections: [{ title: 'Options', rows: [{ id: 'r1', title: 'Row one', description: 'first' }, { id: 'r2', title: 'Row two' }] }] },
+ },
+ });
+ const secondRow = objectAt({ value: requests[0].body, path: 'interactive.action.sections.0.rows.1' });
+ expect(secondRow).not.toHaveProperty('description');
+ });
+
+ test('enforces Meta limits: 1–10 rows, unique ids, 24-char titles, 72-char descriptions, 20-char button, 4096-char body', async () => {
+ await expect(sendInteractiveList.run(context({ ...base, rows: [] }))).rejects.toThrow('between 1 and 10');
+ await expect(sendInteractiveList.run(context({ ...base, rows: Array.from({ length: 11 }, (_, i) => ({ id: `${i}`, title: `T${i}` })) }))).rejects.toThrow(
+ 'between 1 and 10',
+ );
+ await expect(sendInteractiveList.run(context({ ...base, rows: [{ id: 'dup', title: 'A' }, { id: 'dup', title: 'B' }] }))).rejects.toThrow('Row IDs must be unique.');
+ await expect(sendInteractiveList.run(context({ ...base, rows: [{ id: 'a', title: 't'.repeat(25) }] }))).rejects.toThrow('Row title must be at most 24');
+ await expect(sendInteractiveList.run(context({ ...base, rows: [{ id: 'a', title: 'ok', description: 'd'.repeat(73) }] }))).rejects.toThrow(
+ 'Row description must be at most 72',
+ );
+ await expect(sendInteractiveList.run(context({ ...base, button_text: 'b'.repeat(21) }))).rejects.toThrow('Button text must be at most 20');
+ await expect(sendInteractiveList.run(context({ ...base, section_title: 's'.repeat(25) }))).rejects.toThrow('Section title must be at most 24');
+ await expect(sendInteractiveList.run(context({ ...base, body: 'b'.repeat(4097) }))).rejects.toThrow('Body must be at most 4096');
+ expect(requests).toHaveLength(0);
+ });
+});
+
+describe('Send Interactive CTA URL', () => {
+ test('builds the cta_url action with an image header', async () => {
+ await sendInteractiveCtaUrl.run(
+ context({ ...BASE, body: 'Visit', button_text: 'Open', url: 'https://activepieces.com', header_type: 'image', header_media_url: 'https://x/i.png' }),
+ );
+ expect(requests[0].body).toMatchObject({
+ interactive: {
+ type: 'cta_url',
+ header: { type: 'image', image: { link: 'https://x/i.png' } },
+ body: { text: 'Visit' },
+ action: { name: 'cta_url', parameters: { display_text: 'Open', url: 'https://activepieces.com' } },
+ },
+ });
+ });
+
+ test('rejects an over-long button label', async () => {
+ await expect(sendInteractiveCtaUrl.run(context({ ...BASE, body: 'x', button_text: 'l'.repeat(21), url: 'https://a' }))).rejects.toThrow('Button text must be at most 20');
+ expect(requests).toHaveLength(0);
+ });
+});
+
+describe('Send Location', () => {
+ test('sends coordinates as strings and omits empty name/address', async () => {
+ await sendLocation.run(context({ ...BASE, latitude: 31.9539, longitude: 35.9106 }));
+ expect(requests[0].body).toMatchObject({ type: 'location', location: { latitude: '31.9539', longitude: '35.9106' } });
+ expect(objectAt({ value: requests[0].body, path: 'location' })).not.toHaveProperty('name');
+ await sendLocation.run(context({ ...BASE, latitude: 0, longitude: -0.5, name: 'Null Island', address: 'Ocean' }));
+ expect(requests[1].body).toMatchObject({ location: { latitude: '0', longitude: '-0.5', name: 'Null Island', address: 'Ocean' } });
+ });
+});
+
+describe('Send Contact', () => {
+ test('includes only the provided sections and requires a phone on each phone entry', async () => {
+ await sendContact.run(context({ ...BASE, formatted_name: 'Jane Doe' }));
+ expect(requests[0].body).toEqual({
+ messaging_product: 'whatsapp',
+ recipient_type: 'individual',
+ to: '962782550213',
+ type: 'contacts',
+ contacts: [{ name: { formatted_name: 'Jane Doe' } }],
+ });
+
+ await sendContact.run(
+ context({
+ ...BASE,
+ formatted_name: 'Jane Doe',
+ first_name: 'Jane',
+ phones: [{ phone: '+15551234567', type: 'WORK' }, { phone: '+15557654321' }],
+ emails: '[{"email":"jane@example.com","type":"WORK"}]',
+ company: 'Acme',
+ website: 'https://acme.test',
+ }),
+ );
+ expect(requests[1].body).toMatchObject({
+ contacts: [
+ {
+ name: { formatted_name: 'Jane Doe', first_name: 'Jane' },
+ phones: [{ phone: '+15551234567', type: 'WORK' }, { phone: '+15557654321' }],
+ emails: [{ email: 'jane@example.com', type: 'WORK' }],
+ org: { company: 'Acme' },
+ urls: [{ url: 'https://acme.test', type: 'WORK' }],
+ },
+ ],
+ });
+
+ await expect(sendContact.run(context({ ...BASE, formatted_name: 'X', phones: [{ type: 'WORK' }] }))).rejects.toThrow('Phone is required');
+ });
+});
+
+describe('Send Reaction and Mark As Read', () => {
+ test('an empty emoji removes the reaction', async () => {
+ await sendReaction.run(context({ ...BASE, message_id: 'wamid.T', emoji: '👍' }));
+ expect(requests[0].body).toMatchObject({ type: 'reaction', reaction: { message_id: 'wamid.T', emoji: '👍' } });
+ await sendReaction.run(context({ ...BASE, message_id: 'wamid.T', emoji: undefined }));
+ expect(requests[1].body).toMatchObject({ reaction: { message_id: 'wamid.T', emoji: '' } });
+ });
+
+ test('mark as read sends status=read and adds the typing indicator only when asked', async () => {
+ respondWith({ success: true });
+ const out = await markMessageAsRead.run(context({ phone_number_id: 'PN', message_id: 'wamid.IN', show_typing_indicator: false }));
+ expect(out).toEqual({ success: true });
+ expect(requests[0].url).toBe('https://graph.facebook.com/v23.0/PN/messages');
+ expect(requests[0].body).toEqual({ messaging_product: 'whatsapp', status: 'read', message_id: 'wamid.IN' });
+ await markMessageAsRead.run(context({ phone_number_id: 'PN', message_id: 'wamid.IN', show_typing_indicator: true }));
+ expect(requests[1].body).toMatchObject({ typing_indicator: { type: 'text' } });
+ });
+});
+
+describe('Upload Media', () => {
+ test('posts a hand-built multipart body with messaging_product, type and the file under its own boundary', async () => {
+ respondWith({ id: 'MEDIA' });
+ const result = await uploadMedia.run(
+ context({ phone_number_id: 'PN', mime_type: 'image/png', file: { filename: 'a.png', data: Buffer.from('png-bytes'), extension: 'png', base64: '' } }),
+ );
+ expect(result).toEqual({ id: 'MEDIA' });
+ const contentType = String(requests[0].headers?.['Content-Type']);
+ expect(contentType).toMatch(/^multipart\/form-data; boundary=----ActivepiecesWhatsApp[0-9a-f]{24}$/);
+ const boundary = contentType.split('boundary=')[1];
+ const body = bufferBody(requests[0].body).toString('utf8');
+ expect(body).toContain(`--${boundary}\r\nContent-Disposition: form-data; name="messaging_product"\r\n\r\nwhatsapp\r\n`);
+ expect(body).toContain(`--${boundary}\r\nContent-Disposition: form-data; name="type"\r\n\r\nimage/png\r\n`);
+ expect(body).toContain(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.png"\r\nContent-Type: image/png\r\n\r\npng-bytes\r\n`);
+ expect(body.endsWith(`--${boundary}--\r\n`)).toBe(true);
+ });
+});
+
+describe('Templates', () => {
+ test('create converts [[n]] markers and posts to the WABA', async () => {
+ respondWith({ id: 't1', status: 'PENDING', category: 'UTILITY' });
+ const out = await createMessageTemplate.run(
+ context({ name: 'order_update', language: 'en_US', category: 'UTILITY', components: [{ type: 'BODY', text: 'Order [[1]] shipped.', example: { body_text: [['A1']] } }] }),
+ );
+ expect(out).toEqual({ id: 't1', status: 'PENDING', category: 'UTILITY' });
+ expect(requests[0].url).toBe('https://graph.facebook.com/v23.0/WABA/message_templates');
+ expect(requests[0].body).toEqual({
+ name: 'order_update',
+ language: 'en_US',
+ category: 'UTILITY',
+ components: [{ type: 'BODY', text: 'Order {{1}} shipped.', example: { body_text: [['A1']] } }],
+ });
+ });
+
+ test('delete resolves the template name first, then deletes by hsm_id and name', async () => {
+ respondWith({ id: 't1', name: 'order_update' }, { success: true });
+ const out = await deleteMessageTemplate.run(context({ message_template_id: 't1' }));
+ expect(out).toEqual({ success: true });
+ expect(requests[0]).toMatchObject({ method: HttpMethod.GET, url: 'https://graph.facebook.com/v23.0/t1', queryParams: { fields: 'id,name' } });
+ expect(requests[1]).toMatchObject({
+ method: HttpMethod.DELETE,
+ url: 'https://graph.facebook.com/v23.0/WABA/message_templates',
+ queryParams: { hsm_id: 't1', name: 'order_update' },
+ });
+ });
+
+ test('list passes the status filter only when set and returns a count', async () => {
+ respondWith({ data: [{ id: 'a' }, { id: 'b' }] });
+ const out = await listMessageTemplates.run(context({ status: undefined, limit: undefined }));
+ expect(out).toEqual({ templates: [{ id: 'a' }, { id: 'b' }], count: 2 });
+ expect(requests[0].queryParams).toEqual({ fields: 'id,name,language,category,status,components,quality_score', limit: '100' });
+ await listMessageTemplates.run(context({ status: 'APPROVED', limit: 5 }));
+ expect(requests[1].queryParams).toMatchObject({ status: 'APPROVED', limit: '5' });
+ });
+});
+
+describe('List Phone Numbers', () => {
+ test('follows the after cursor until the last page and counts every number', async () => {
+ respondWith(
+ { data: [{ id: '1' }, { id: '2' }], paging: { cursors: { after: 'c1' }, next: 'https://graph/next' } },
+ { data: [{ id: '3' }], paging: { cursors: { after: 'c2' } } },
+ );
+ const result = await listPhoneNumbers.run(context({}));
+ expect(requests).toHaveLength(2);
+ expect(requests[0].queryParams).toMatchObject({ limit: '100' });
+ expect(requests[0].queryParams).not.toHaveProperty('after');
+ expect(requests[1].queryParams).toMatchObject({ after: 'c1' });
+ expect(result).toEqual({ phone_numbers: [{ id: '1' }, { id: '2' }, { id: '3' }], count: 3 });
+ });
+});
+
+describe('Business profile', () => {
+ test('get unwraps data[0]; update sends only filled fields plus messaging_product', async () => {
+ respondWith({ data: [{ messaging_product: 'whatsapp', vertical: 'OTHER' }] });
+ expect(await getBusinessProfile.run(context({ phone_number_id: 'PN' }))).toEqual({ messaging_product: 'whatsapp', vertical: 'OTHER' });
+ expect(requests[0].url).toBe('https://graph.facebook.com/v23.0/PN/whatsapp_business_profile');
+
+ respondWith({ success: true });
+ await updateBusinessProfile.run(context({ phone_number_id: 'PN', about: 'Hi', address: '', email: undefined, websites: ['https://a.test', ''], vertical: undefined }));
+ expect(requests[1].body).toEqual({ messaging_product: 'whatsapp', about: 'Hi', websites: ['https://a.test'] });
+ });
+});
+
+type ObjectAtParams = { value: unknown; path: string };
diff --git a/packages/pieces/community/whatsapp/test/client.test.ts b/packages/pieces/community/whatsapp/test/client.test.ts
new file mode 100644
index 000000000000..169ba84d708a
--- /dev/null
+++ b/packages/pieces/community/whatsapp/test/client.test.ts
@@ -0,0 +1,107 @@
+///
+
+import { httpClient, HttpMethod, HttpRequest, HttpResponse } from '@activepieces/pieces-common';
+import { whatsappClient } from '../src/lib/common/client';
+import { WHATSAPP_API_BASE } from '../src/lib/common/utils';
+
+let lastRequest: HttpRequest | undefined;
+
+beforeEach(() => {
+ lastRequest = undefined;
+ vi.spyOn(httpClient, 'sendRequest').mockImplementation(async (request: HttpRequest): Promise => {
+ lastRequest = request;
+ return { status: 200, headers: {}, body: { ok: true } };
+ });
+});
+
+afterEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe('WHATSAPP_API_BASE', () => {
+ test('pins a single, current Graph API version for the whole piece', () => {
+ expect(WHATSAPP_API_BASE).toBe('https://graph.facebook.com/v23.0');
+ });
+});
+
+describe('request', () => {
+ test('builds the URL from the base, sends a bearer token and returns only the body', async () => {
+ const body = await whatsappClient.request({ accessToken: 'tok', method: HttpMethod.GET, path: '/123', queryParams: { fields: 'id' } });
+ expect(body).toEqual({ ok: true });
+ expect(lastRequest?.url).toBe('https://graph.facebook.com/v23.0/123');
+ expect(lastRequest?.authentication).toEqual({ type: 'BEARER_TOKEN', token: 'tok' });
+ expect(lastRequest?.queryParams).toEqual({ fields: 'id' });
+ });
+});
+
+describe('sendMessage', () => {
+ test('posts to /{phone}/messages with the WhatsApp envelope and the payload merged in', async () => {
+ await whatsappClient.sendMessage({
+ accessToken: 'tok',
+ phoneNumberId: 'PN',
+ to: '962782550213',
+ payload: { type: 'text', text: { body: 'hi' } },
+ });
+ expect(lastRequest?.method).toBe(HttpMethod.POST);
+ expect(lastRequest?.url).toBe('https://graph.facebook.com/v23.0/PN/messages');
+ expect(lastRequest?.body).toEqual({
+ messaging_product: 'whatsapp',
+ recipient_type: 'individual',
+ to: '962782550213',
+ type: 'text',
+ text: { body: 'hi' },
+ });
+ });
+
+ test('adds a context.message_id only when replying to a message', async () => {
+ await whatsappClient.sendMessage({ accessToken: 't', phoneNumberId: 'PN', to: '1', replyToMessageId: 'wamid.X', payload: { type: 'text' } });
+ expect(lastRequest?.body).toMatchObject({ context: { message_id: 'wamid.X' } });
+ await whatsappClient.sendMessage({ accessToken: 't', phoneNumberId: 'PN', to: '1', replyToMessageId: '', payload: { type: 'text' } });
+ expect(lastRequest?.body).not.toHaveProperty('context');
+ });
+});
+
+describe('buildInteractiveHeader', () => {
+ test('none or missing type yields no header', () => {
+ expect(whatsappClient.buildInteractiveHeader({ headerType: 'none', headerText: 'x' })).toBeUndefined();
+ expect(whatsappClient.buildInteractiveHeader({ headerType: undefined, headerText: 'x' })).toBeUndefined();
+ });
+
+ test('text header without text and media header without a URL are rejected instead of silently dropped', () => {
+ expect(whatsappClient.buildInteractiveHeader({ headerType: 'text', headerText: 'Hello' })).toEqual({ type: 'text', text: 'Hello' });
+ expect(() => whatsappClient.buildInteractiveHeader({ headerType: 'text', headerText: '' })).toThrow('Header Text is required');
+ expect(whatsappClient.buildInteractiveHeader({ headerType: 'image', headerMediaUrl: 'https://x/y.png' })).toEqual({
+ type: 'image',
+ image: { link: 'https://x/y.png' },
+ });
+ expect(() => whatsappClient.buildInteractiveHeader({ headerType: 'video', headerMediaUrl: '' })).toThrow('Header Media URL is required when the header type is video');
+ expect(whatsappClient.buildInteractiveHeader({ headerType: 'document', headerMediaUrl: 'https://x/a.pdf' })).toEqual({
+ type: 'document',
+ document: { link: 'https://x/a.pdf' },
+ });
+ });
+});
+
+describe('normalizeTemplateComponents', () => {
+ test('converts [[n]] and [[name]] markers to WhatsApp double braces at any depth', () => {
+ const input = [
+ { type: 'HEADER', format: 'TEXT', text: 'Hi [[1]]' },
+ { type: 'BODY', text: 'Order [[order_id]] ships [[2]].', example: { body_text: [['A1', 'today']] } },
+ { type: 'BUTTONS', buttons: [{ type: 'URL', text: 'Track', url: 'https://x/[[1]]' }] },
+ ];
+ expect(whatsappClient.normalizeTemplateComponents(input)).toEqual([
+ { type: 'HEADER', format: 'TEXT', text: 'Hi {{1}}' },
+ { type: 'BODY', text: 'Order {{order_id}} ships {{2}}.', example: { body_text: [['A1', 'today']] } },
+ { type: 'BUTTONS', buttons: [{ type: 'URL', text: 'Track', url: 'https://x/{{1}}' }] },
+ ]);
+ });
+
+ test('leaves numbers, booleans, null and unrelated brackets untouched and does not mutate the input', () => {
+ const input = { n: 1, b: true, z: null, s: 'a [b] [[c d]] [[ok]]' };
+ const out = whatsappClient.normalizeTemplateComponents(input);
+ expect(out).toEqual({ n: 1, b: true, z: null, s: 'a [b] [[c d]] {{ok}}' });
+ expect(input.s).toBe('a [b] [[c d]] [[ok]]');
+ expect(whatsappClient.normalizeTemplateComponents('plain')).toBe('plain');
+ expect(whatsappClient.normalizeTemplateComponents(undefined)).toBeUndefined();
+ });
+});
diff --git a/packages/pieces/community/whatsapp/test/inputs.test.ts b/packages/pieces/community/whatsapp/test/inputs.test.ts
new file mode 100644
index 000000000000..f55389359616
--- /dev/null
+++ b/packages/pieces/community/whatsapp/test/inputs.test.ts
@@ -0,0 +1,80 @@
+///
+
+import { inputUtils } from '../src/lib/common/inputs';
+
+describe('asRecords', () => {
+ test('keeps only object entries of an array', () => {
+ expect(inputUtils.asRecords([{ id: 'a' }, 'x', null, 3, [{ id: 'nested' }], { id: 'b' }])).toEqual([{ id: 'a' }, { id: 'b' }]);
+ });
+
+ test('accepts a JSON-encoded array string, as the builder sends when a prop is toggled to dynamic', () => {
+ expect(inputUtils.asRecords('[{"id":"a","title":"A"}]')).toEqual([{ id: 'a', title: 'A' }]);
+ expect(inputUtils.asRecords(' [{"id":"a"}] ')).toEqual([{ id: 'a' }]);
+ });
+
+ test('returns an empty list for anything that is not an array', () => {
+ expect(inputUtils.asRecords(undefined)).toEqual([]);
+ expect(inputUtils.asRecords(null)).toEqual([]);
+ expect(inputUtils.asRecords({ id: 'a' })).toEqual([]);
+ expect(inputUtils.asRecords('not json')).toEqual([]);
+ expect(inputUtils.asRecords('[broken')).toEqual([]);
+ expect(inputUtils.asRecords('{"id":"a"}')).toEqual([]);
+ });
+});
+
+describe('asStrings', () => {
+ test('keeps non-empty strings only, from arrays or JSON strings', () => {
+ expect(inputUtils.asStrings(['a', '', 1, null, 'b'])).toEqual(['a', 'b']);
+ expect(inputUtils.asStrings('["x","y"]')).toEqual(['x', 'y']);
+ expect(inputUtils.asStrings('plain')).toEqual([]);
+ expect(inputUtils.asStrings(undefined)).toEqual([]);
+ });
+});
+
+describe('requiredString / optionalString', () => {
+ test('reads a string field and treats empty or non-string values as missing', () => {
+ expect(inputUtils.optionalString({ record: { a: 'x' }, key: 'a' })).toBe('x');
+ expect(inputUtils.optionalString({ record: { a: '' }, key: 'a' })).toBeUndefined();
+ expect(inputUtils.optionalString({ record: { a: 5 }, key: 'a' })).toBeUndefined();
+ expect(inputUtils.optionalString({ record: {}, key: 'a' })).toBeUndefined();
+ });
+
+ test('requiredString names the field in its error', () => {
+ expect(() => inputUtils.requiredString({ record: {}, key: 'id', label: 'Button ID' })).toThrow('Button ID is required on every entry.');
+ expect(inputUtils.requiredString({ record: { id: 'ok' }, key: 'id', label: 'Button ID' })).toBe('ok');
+ });
+
+ test('maxLength is enforced with the actual length in the message and is inclusive', () => {
+ expect(inputUtils.requiredString({ record: { t: 'x'.repeat(20) }, key: 't', label: 'Title', maxLength: 20 })).toHaveLength(20);
+ expect(() => inputUtils.requiredString({ record: { t: 'x'.repeat(21) }, key: 't', label: 'Title', maxLength: 20 })).toThrow(
+ 'Title must be at most 20 characters (got 21).',
+ );
+ expect(() => inputUtils.optionalString({ record: { d: 'y'.repeat(73) }, key: 'd', label: 'Description', maxLength: 72 })).toThrow(
+ 'Description must be at most 72 characters (got 73).',
+ );
+ expect(inputUtils.optionalString({ record: {}, key: 'd', label: 'Description', maxLength: 72 })).toBeUndefined();
+ });
+});
+
+describe('assertMaxLength / assertUnique', () => {
+ test('assertMaxLength ignores undefined and empty values', () => {
+ expect(() => inputUtils.assertMaxLength({ value: undefined, maxLength: 1, label: 'X' })).not.toThrow();
+ expect(() => inputUtils.assertMaxLength({ value: '', maxLength: 1, label: 'X' })).not.toThrow();
+ expect(() => inputUtils.assertMaxLength({ value: 'ab', maxLength: 1, label: 'X' })).toThrow('X must be at most 1 characters (got 2).');
+ });
+
+ test('assertUnique rejects duplicates and accepts distinct or empty lists', () => {
+ expect(() => inputUtils.assertUnique({ values: ['Yes', 'No', 'Yes'], label: 'Button titles' })).toThrow('Button titles must be unique.');
+ expect(() => inputUtils.assertUnique({ values: ['Yes', 'No'], label: 'Button titles' })).not.toThrow();
+ expect(() => inputUtils.assertUnique({ values: [], label: 'Button titles' })).not.toThrow();
+ });
+});
+
+describe('isRecord', () => {
+ test('distinguishes plain objects from arrays, null and primitives', () => {
+ expect(inputUtils.isRecord({})).toBe(true);
+ expect(inputUtils.isRecord([])).toBe(false);
+ expect(inputUtils.isRecord(null)).toBe(false);
+ expect(inputUtils.isRecord('s')).toBe(false);
+ });
+});
diff --git a/packages/pieces/community/whatsapp/test/webhook.test.ts b/packages/pieces/community/whatsapp/test/webhook.test.ts
new file mode 100644
index 000000000000..332879beb034
--- /dev/null
+++ b/packages/pieces/community/whatsapp/test/webhook.test.ts
@@ -0,0 +1,302 @@
+///
+
+import { createHmac } from 'crypto';
+
+import { ChangeValue, IncomingMessage, MessageStatus, whatsappWebhook } from '../src/lib/common/webhook';
+
+const METADATA = { display_phone_number: '15551394669', phone_number_id: '1285944454608901' };
+
+function wabaBody({ changes, entryId = 'waba-1' }: WabaBodyParams) {
+ return { object: 'whatsapp_business_account', entry: [{ id: entryId, changes }] };
+}
+
+function sign({ secret, body }: SignParams): string {
+ return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`;
+}
+
+function textMessage(overrides: Partial = {}): IncomingMessage {
+ return {
+ from: '962782550213',
+ id: 'wamid.TEXT',
+ timestamp: '1789520000',
+ type: 'text',
+ text: { body: 'hello' },
+ ...overrides,
+ };
+}
+
+function value(overrides: Partial = {}): ChangeValue {
+ return {
+ waba_id: 'waba-1',
+ messaging_product: 'whatsapp',
+ metadata: METADATA,
+ contacts: [{ profile: { name: 'Odai' }, wa_id: '962782550213' }],
+ ...overrides,
+ };
+}
+
+describe('extractChanges', () => {
+ test('returns nothing for a body that is not a WhatsApp Business Account event', () => {
+ expect(whatsappWebhook.extractChanges({ body: { object: 'page', entry: [] }, field: 'messages' })).toEqual([]);
+ expect(whatsappWebhook.extractChanges({ body: undefined, field: 'messages' })).toEqual([]);
+ expect(whatsappWebhook.extractChanges({ body: 'not json', field: 'messages' })).toEqual([]);
+ expect(whatsappWebhook.extractChanges({ body: { object: 'whatsapp_business_account' }, field: 'messages' })).toEqual([]);
+ });
+
+ test('keeps only changes for the requested field and attaches the WABA id', () => {
+ const body = wabaBody({
+ changes: [
+ { field: 'messages', value: { messages: [textMessage()] } },
+ { field: 'message_template_status_update', value: { event: 'APPROVED' } },
+ ],
+ });
+ const messages = whatsappWebhook.extractChanges({ body, field: 'messages' });
+ const templates = whatsappWebhook.extractChanges({ body, field: 'message_template_status_update' });
+ expect(messages).toHaveLength(1);
+ expect(messages[0].waba_id).toBe('waba-1');
+ expect(messages[0].messages).toHaveLength(1);
+ expect(templates).toHaveLength(1);
+ expect(templates[0].event).toBe('APPROVED');
+ });
+
+ test('flattens across several entries and tolerates an entry without changes', () => {
+ const body = {
+ object: 'whatsapp_business_account',
+ entry: [
+ { id: 'a', changes: [{ field: 'messages', value: { messages: [textMessage()] } }] },
+ { id: 'b' },
+ { id: 'c', changes: [{ field: 'messages', value: { statuses: [] } }] },
+ ],
+ };
+ const changes = whatsappWebhook.extractChanges({ body, field: 'messages' });
+ expect(changes.map((change) => change.waba_id)).toEqual(['a', 'c']);
+ });
+});
+
+describe('matchesPhoneNumber', () => {
+ test('accepts every event when no filter is set', () => {
+ expect(whatsappWebhook.matchesPhoneNumber({ value: value(), phoneNumberId: undefined })).toBe(true);
+ expect(whatsappWebhook.matchesPhoneNumber({ value: value(), phoneNumberId: '' })).toBe(true);
+ });
+
+ test('filters on the metadata phone number id', () => {
+ expect(whatsappWebhook.matchesPhoneNumber({ value: value(), phoneNumberId: '1285944454608901' })).toBe(true);
+ expect(whatsappWebhook.matchesPhoneNumber({ value: value(), phoneNumberId: 'other' })).toBe(false);
+ expect(whatsappWebhook.matchesPhoneNumber({ value: value({ metadata: undefined }), phoneNumberId: 'other' })).toBe(false);
+ });
+});
+
+describe('flattenIncomingMessage', () => {
+ test('text message: core fields, contact name and ISO timestamp', () => {
+ const flat = whatsappWebhook.flattenIncomingMessage({ message: textMessage(), value: value() });
+ expect(flat).toMatchObject({
+ message_id: 'wamid.TEXT',
+ from: '962782550213',
+ contact_name: 'Odai',
+ timestamp: '1789520000',
+ received_at: '2026-09-16T00:53:20.000Z',
+ type: 'text',
+ phone_number_id: '1285944454608901',
+ display_phone_number: '15551394669',
+ text: 'hello',
+ media_id: null,
+ interactive_reply_id: null,
+ reaction_emoji: null,
+ context_message_id: null,
+ forwarded: false,
+ });
+ expect(flat.raw).toBe(flat.raw);
+ });
+
+ test('a non-numeric or missing timestamp yields a null received_at instead of throwing', () => {
+ const bad = whatsappWebhook.flattenIncomingMessage({ message: textMessage({ timestamp: 'not-a-number' }), value: value() });
+ expect(bad.received_at).toBeNull();
+ const empty = whatsappWebhook.flattenIncomingMessage({ message: textMessage({ timestamp: '' }), value: value() });
+ expect(empty.received_at).toBeNull();
+ });
+
+ test('picks the contact matching the sender, falling back to the first contact, then null', () => {
+ const twoContacts = value({
+ contacts: [
+ { profile: { name: 'Someone Else' }, wa_id: '111' },
+ { profile: { name: 'Odai' }, wa_id: '962782550213' },
+ ],
+ });
+ expect(whatsappWebhook.flattenIncomingMessage({ message: textMessage(), value: twoContacts }).contact_name).toBe('Odai');
+ const unrelated = value({ contacts: [{ profile: { name: 'Fallback' }, wa_id: '999' }] });
+ expect(whatsappWebhook.flattenIncomingMessage({ message: textMessage(), value: unrelated }).contact_name).toBe('Fallback');
+ expect(whatsappWebhook.flattenIncomingMessage({ message: textMessage(), value: value({ contacts: undefined }) }).contact_name).toBeNull();
+ expect(whatsappWebhook.flattenIncomingMessage({ message: textMessage(), value: value({ metadata: undefined }) }).phone_number_id).toBeNull();
+ });
+
+ test('media messages expose the media id, mime type, hash, caption and document filename', () => {
+ const image = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({ type: 'image', text: undefined, image: { id: 'm1', mime_type: 'image/jpeg', sha256: 'abc', caption: 'pic' } }),
+ value: value(),
+ });
+ expect(image).toMatchObject({ type: 'image', text: null, media_id: 'm1', mime_type: 'image/jpeg', sha256: 'abc', caption: 'pic', filename: null });
+
+ const doc = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({ type: 'document', text: undefined, document: { id: 'd1', mime_type: 'application/pdf', sha256: 'h', filename: 'invoice.pdf' } }),
+ value: value(),
+ });
+ expect(doc).toMatchObject({ media_id: 'd1', filename: 'invoice.pdf', caption: null });
+ });
+
+ test('location, interactive replies, template buttons, reactions and reply context are flattened', () => {
+ const location = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({ type: 'location', text: undefined, location: { latitude: 31.95, longitude: 35.91, name: 'Amman', address: 'JO' } }),
+ value: value(),
+ });
+ expect(location).toMatchObject({ latitude: 31.95, longitude: 35.91, location_name: 'Amman', location_address: 'JO' });
+
+ const button = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({
+ type: 'interactive',
+ text: undefined,
+ interactive: { type: 'button_reply', button_reply: { id: 'yes', title: 'Yes' } },
+ context: { from: '15551394669', id: 'wamid.ORIGINAL', forwarded: true },
+ }),
+ value: value(),
+ });
+ expect(button).toMatchObject({
+ interactive_type: 'button_reply',
+ interactive_reply_id: 'yes',
+ interactive_reply_title: 'Yes',
+ interactive_reply_description: null,
+ context_message_id: 'wamid.ORIGINAL',
+ context_from: '15551394669',
+ forwarded: true,
+ });
+
+ const list = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({ type: 'interactive', text: undefined, interactive: { type: 'list_reply', list_reply: { id: 'r1', title: 'Row', description: 'first' } } }),
+ value: value(),
+ });
+ expect(list).toMatchObject({ interactive_reply_id: 'r1', interactive_reply_title: 'Row', interactive_reply_description: 'first' });
+
+ const templateButton = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({ type: 'button', text: undefined, button: { payload: 'STOP', text: 'Stop' } }),
+ value: value(),
+ });
+ expect(templateButton).toMatchObject({ button_payload: 'STOP', button_text: 'Stop' });
+
+ const reaction = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({ type: 'reaction', text: undefined, reaction: { message_id: 'wamid.X', emoji: '👍' } }),
+ value: value(),
+ });
+ expect(reaction).toMatchObject({ reaction_emoji: '👍', reaction_message_id: 'wamid.X' });
+
+ const removed = whatsappWebhook.flattenIncomingMessage({
+ message: textMessage({ type: 'reaction', text: undefined, reaction: { message_id: 'wamid.X' } }),
+ value: value(),
+ });
+ expect(removed.reaction_emoji).toBeNull();
+ });
+});
+
+describe('flattenStatus', () => {
+ const delivered: MessageStatus = {
+ id: 'wamid.SENT',
+ status: 'delivered',
+ timestamp: '1789520050',
+ recipient_id: '962782550213',
+ conversation: { id: 'conv', origin: { type: 'utility' }, expiration_timestamp: '1789606400' },
+ pricing: { billable: true, pricing_model: 'CBP', category: 'utility' },
+ };
+
+ test('delivered status with conversation and pricing', () => {
+ expect(whatsappWebhook.flattenStatus({ status: delivered, value: value() })).toEqual({
+ message_id: 'wamid.SENT',
+ status: 'delivered',
+ recipient_id: '962782550213',
+ timestamp: '1789520050',
+ updated_at: '2026-09-16T00:54:10.000Z',
+ phone_number_id: '1285944454608901',
+ display_phone_number: '15551394669',
+ conversation_id: 'conv',
+ conversation_origin: 'utility',
+ conversation_expires_at: '2026-09-17T00:53:20.000Z',
+ billable: true,
+ pricing_model: 'CBP',
+ pricing_category: 'utility',
+ error_code: null,
+ error_title: null,
+ error_message: null,
+ error_details: null,
+ raw: delivered,
+ });
+ });
+
+ test('failed status surfaces the first error and nulls the missing conversation', () => {
+ const failed: MessageStatus = {
+ id: 'wamid.FAIL',
+ status: 'failed',
+ timestamp: 'garbage',
+ recipient_id: '962782550213',
+ errors: [{ code: 131026, title: 'Message undeliverable', message: 'Message undeliverable.', error_data: { details: 'Not on WhatsApp' } }],
+ };
+ expect(whatsappWebhook.flattenStatus({ status: failed, value: value() })).toMatchObject({
+ status: 'failed',
+ updated_at: null,
+ conversation_id: null,
+ conversation_expires_at: null,
+ billable: null,
+ error_code: 131026,
+ error_title: 'Message undeliverable',
+ error_message: 'Message undeliverable.',
+ error_details: 'Not on WhatsApp',
+ });
+ });
+});
+
+describe('handleHandshake', () => {
+ test('echoes the challenge as text/plain when no verify token is configured', () => {
+ expect(
+ whatsappWebhook.handleHandshake({
+ queryParams: { 'hub.mode': 'subscribe', 'hub.verify_token': 'anything', 'hub.challenge': '12345' },
+ expectedToken: undefined,
+ }),
+ ).toEqual({ status: 200, body: '12345', headers: { 'Content-Type': 'text/plain' } });
+ });
+
+ test('accepts a matching token and rejects a mismatch with 403', () => {
+ const good = whatsappWebhook.handleHandshake({ queryParams: { 'hub.verify_token': 'secret', 'hub.challenge': 'c' }, expectedToken: 'secret' });
+ expect(good.status).toBe(200);
+ expect(good.body).toBe('c');
+ const bad = whatsappWebhook.handleHandshake({ queryParams: { 'hub.verify_token': 'wrong', 'hub.challenge': 'c' }, expectedToken: 'secret' });
+ expect(bad.status).toBe(403);
+ expect(bad.body).not.toBe('c');
+ const missing = whatsappWebhook.handleHandshake({ queryParams: { 'hub.challenge': 'c' }, expectedToken: 'secret' });
+ expect(missing.status).toBe(403);
+ });
+
+ test('a missing challenge still answers 200 with an empty body', () => {
+ expect(whatsappWebhook.handleHandshake({ queryParams: {}, expectedToken: undefined }).body).toBe('');
+ });
+});
+
+describe('delivery signature', () => {
+ const raw = JSON.stringify({ object: 'whatsapp_business_account', entry: [] });
+
+ test('without an app secret every delivery is accepted, as before', () => {
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: undefined, headers: {}, rawBody: raw })).toBe(true);
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: '', headers: {}, rawBody: undefined })).toBe(true);
+ });
+
+ test('with an app secret a valid signature over the raw body is required', () => {
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: 's3cret', headers: { 'x-hub-signature-256': sign({ secret: 's3cret', body: raw }) }, rawBody: raw })).toBe(true);
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: 's3cret', headers: { 'x-hub-signature-256': sign({ secret: 's3cret', body: raw }) }, rawBody: Buffer.from(raw) })).toBe(true);
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: 's3cret', headers: { 'x-hub-signature-256': sign({ secret: 'other', body: raw }) }, rawBody: raw })).toBe(false);
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: 's3cret', headers: { 'x-hub-signature-256': sign({ secret: 's3cret', body: raw + ' ' }) }, rawBody: raw })).toBe(false);
+ });
+
+ test('a missing header, wrong prefix or missing raw body is rejected when a secret is set', () => {
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: 's3cret', headers: {}, rawBody: raw })).toBe(false);
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: 's3cret', headers: { 'x-hub-signature-256': 'sha1=abc' }, rawBody: raw })).toBe(false);
+ expect(whatsappWebhook.isSignedByMeta({ appSecret: 's3cret', headers: { 'x-hub-signature-256': sign({ secret: 's3cret', body: raw }) }, rawBody: undefined })).toBe(false);
+ });
+});
+
+type WabaBodyParams = { changes: { field: string; value: Record }[]; entryId?: string };
+type SignParams = { secret: string; body: string };
diff --git a/packages/pieces/community/whatsapp/vitest.config.ts b/packages/pieces/community/whatsapp/vitest.config.ts
new file mode 100644
index 000000000000..5ad1d9d905fa
--- /dev/null
+++ b/packages/pieces/community/whatsapp/vitest.config.ts
@@ -0,0 +1,17 @@
+import path from 'path';
+import { defineConfig } from 'vitest/config';
+
+const repoRoot = path.resolve(__dirname, '../../../..');
+
+export default defineConfig({
+ test: {
+ globals: true,
+ environment: 'node',
+ },
+ resolve: {
+ alias: {
+ '@activepieces/pieces-framework': path.resolve(repoRoot, 'packages/pieces/framework/src/index.ts'),
+ '@activepieces/pieces-common': path.resolve(repoRoot, 'packages/pieces/common/src/index.ts'),
+ },
+ },
+});
diff --git a/packages/server/api/src/app/ai/ai-provider-service.ts b/packages/server/api/src/app/ai/ai-provider-service.ts
index bbde89f949aa..940eac9eed64 100644
--- a/packages/server/api/src/app/ai/ai-provider-service.ts
+++ b/packages/server/api/src/app/ai/ai-provider-service.ts
@@ -1,5 +1,5 @@
import { ActivepiecesError, AiProviderKeyStatus, AIProviderName, apId, classifyProviderOutcome, ErrorCode, isNil, PlatformId, ProviderOutcomeSignal, spreadIfDefined, spreadIfNotUndefined, toProviderOutcomeSignal, tryCatch, unique } from '@activepieces/core-utils'
-import { modelCatalog } from '@activepieces/server-utils'
+import { modelCatalog, modelTierCatalog } from '@activepieces/server-utils'
import { ActivePiecesProviderAuthConfig, AI_PROVIDER_ENTITY_TYPES, AIProviderAuthConfig, AIProviderConfig, aiProviderCredentials, AIProviderModel, AiProviderProjectScope, AIProviderWithoutSensitiveData, CreateAIProviderRequest, GetProviderConfigResponse, ProjectAIProvider, UpdateAIProviderRequest } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import cron from 'node-cron'
@@ -28,6 +28,7 @@ const CONFIRM_MIN_INTERVAL_SECONDS = 10
export const aiProviderService = (log: FastifyBaseLogger) => ({
async setup(): Promise {
+ await modelTierCatalog.warmUp()
cron.schedule('0 0 * * *', () => {
log.info('Clearing AI provider models cache')
modelsCache.clear()
diff --git a/packages/server/api/src/app/ai/providers/ai-provider.ts b/packages/server/api/src/app/ai/providers/ai-provider.ts
index 39bb189b244f..ec726e72050c 100644
--- a/packages/server/api/src/app/ai/providers/ai-provider.ts
+++ b/packages/server/api/src/app/ai/providers/ai-provider.ts
@@ -1,6 +1,8 @@
import { AIProviderAuthConfig, AIProviderConfig, AIProviderModel } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
+export const MODEL_LIST_TIMEOUT_MS = 15_000
+
export type AIProviderStrategy = {
name: string
listModels(authConfig: T, config: C): Promise
diff --git a/packages/server/api/src/app/ai/providers/google-provider.ts b/packages/server/api/src/app/ai/providers/google-provider.ts
index ed21d932ebe3..93cfd880765c 100644
--- a/packages/server/api/src/app/ai/providers/google-provider.ts
+++ b/packages/server/api/src/app/ai/providers/google-provider.ts
@@ -1,7 +1,7 @@
import { httpClient, HttpMethod } from '@activepieces/pieces-common'
import { AIProviderModel, AIProviderModelType, GoogleProviderAuthConfig, GoogleProviderConfig } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
-import { AIProviderStrategy } from './ai-provider'
+import { AIProviderStrategy, MODEL_LIST_TIMEOUT_MS } from './ai-provider'
export const googleProvider: AIProviderStrategy = {
name: 'Google',
@@ -12,6 +12,7 @@ export const googleProvider: AIProviderStrategy({
url: 'https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000',
method: HttpMethod.GET,
+ timeout: MODEL_LIST_TIMEOUT_MS,
headers: {
'x-goog-api-key': authConfig.apiKey,
'Content-Type': 'application/json',
diff --git a/packages/server/api/src/app/ai/providers/openai-provider.ts b/packages/server/api/src/app/ai/providers/openai-provider.ts
index 8ff3ee378aab..63450e6931cb 100644
--- a/packages/server/api/src/app/ai/providers/openai-provider.ts
+++ b/packages/server/api/src/app/ai/providers/openai-provider.ts
@@ -1,7 +1,7 @@
import { httpClient, HttpMethod } from '@activepieces/pieces-common'
import { AIProviderModel, AIProviderModelType, OpenAIProviderAuthConfig, OpenAIProviderConfig } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
-import { AIProviderStrategy } from './ai-provider'
+import { AIProviderStrategy, MODEL_LIST_TIMEOUT_MS } from './ai-provider'
export const openaiProvider: AIProviderStrategy = {
name: 'OpenAI',
@@ -12,6 +12,7 @@ export const openaiProvider: AIProviderStrategy({
url: 'https://api.openai.com/v1/models',
method: HttpMethod.GET,
+ timeout: MODEL_LIST_TIMEOUT_MS,
headers: {
'Authorization': `Bearer ${authConfig.apiKey}`,
'Content-Type': 'application/json',
diff --git a/packages/server/api/src/app/ai/providers/openrouter-provider.ts b/packages/server/api/src/app/ai/providers/openrouter-provider.ts
index f7853ed4a5fe..d149db8b7d47 100644
--- a/packages/server/api/src/app/ai/providers/openrouter-provider.ts
+++ b/packages/server/api/src/app/ai/providers/openrouter-provider.ts
@@ -1,7 +1,7 @@
import { httpClient, HttpMethod } from '@activepieces/pieces-common'
import { AIProviderModel, AIProviderModelType, OpenRouterProviderAuthConfig, OpenRouterProviderConfig } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
-import { AIProviderStrategy } from './ai-provider'
+import { AIProviderStrategy, MODEL_LIST_TIMEOUT_MS } from './ai-provider'
export const openRouterProvider: AIProviderStrategy = {
name: 'OpenRouter',
@@ -19,6 +19,7 @@ export const openRouterProvider: AIProviderStrategy({
url: 'https://openrouter.ai/api/v1/models',
method: HttpMethod.GET,
+ timeout: MODEL_LIST_TIMEOUT_MS,
headers: {
'Content-Type': 'application/json',
},
diff --git a/packages/server/api/src/app/core/db/unique-violation.ts b/packages/server/api/src/app/core/db/unique-violation.ts
new file mode 100644
index 000000000000..ca3b4e8f4d18
--- /dev/null
+++ b/packages/server/api/src/app/core/db/unique-violation.ts
@@ -0,0 +1,16 @@
+import { QueryFailedError } from 'typeorm'
+
+export function isUniqueViolation(error: unknown): boolean {
+ if (!(error instanceof QueryFailedError)) {
+ return false
+ }
+ const driverError: unknown = error.driverError
+ return (
+ typeof driverError === 'object' &&
+ driverError !== null &&
+ 'code' in driverError &&
+ driverError.code === POSTGRES_UNIQUE_VIOLATION
+ )
+}
+
+const POSTGRES_UNIQUE_VIOLATION = '23505'
diff --git a/packages/server/api/src/app/database/migration/postgres/1852000000000-UniqueProjectRoleNamePerPlatform.ts b/packages/server/api/src/app/database/migration/postgres/1852000000000-UniqueProjectRoleNamePerPlatform.ts
new file mode 100644
index 000000000000..7682c03f0da2
--- /dev/null
+++ b/packages/server/api/src/app/database/migration/postgres/1852000000000-UniqueProjectRoleNamePerPlatform.ts
@@ -0,0 +1,64 @@
+import { QueryRunner } from 'typeorm'
+import { Migration } from '../../migration'
+
+export class UniqueProjectRoleNamePerPlatform1852000000000 implements Migration {
+ name = 'UniqueProjectRoleNamePerPlatform1852000000000'
+ breaking = false
+ release = '0.92.0'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ WITH ranked AS (
+ SELECT pr."id", pr."platformId", pr."name", LOWER(pr."name") AS lower_name, pr."created",
+ (SELECT COUNT(*) FROM "project_member" pm WHERE pm."projectRoleId" = pr."id") AS member_count
+ FROM "project_role" pr
+ WHERE pr."platformId" IS NOT NULL
+ ),
+ targets AS (
+ SELECT "id", "platformId", "name", lower_name,
+ ROW_NUMBER() OVER (
+ PARTITION BY "platformId", lower_name
+ ORDER BY member_count DESC, "created", "id"
+ ) - 1 AS slot
+ FROM ranked
+ ),
+ dupe_groups AS (
+ SELECT DISTINCT loser."platformId", loser.lower_name, keeper."name" AS base
+ FROM targets loser
+ JOIN targets keeper
+ ON keeper."platformId" = loser."platformId"
+ AND keeper.lower_name = loser.lower_name
+ AND keeper.slot = 0
+ WHERE loser.slot > 0
+ ),
+ free AS (
+ SELECT d."platformId", d.lower_name, d.base, g.n,
+ ROW_NUMBER() OVER (PARTITION BY d."platformId", d.lower_name ORDER BY g.n) AS slot
+ FROM dupe_groups d
+ CROSS JOIN generate_series(2, (SELECT 2 * COUNT(*) + 2 FROM "project_role")) AS g(n)
+ WHERE NOT EXISTS (
+ SELECT 1 FROM "project_role" e
+ WHERE e."platformId" = d."platformId"
+ AND LOWER(e."name") = LOWER(d.base || ' (' || g.n || ')')
+ )
+ )
+ UPDATE "project_role" r
+ SET "name" = f.base || ' (' || f.n || ')'
+ FROM targets t
+ JOIN free f
+ ON f."platformId" = t."platformId" AND f.lower_name = t.lower_name AND f.slot = t.slot
+ WHERE r."id" = t."id" AND t.slot > 0
+ `)
+ await queryRunner.query(`
+ CREATE UNIQUE INDEX "idx_project_role_platform_id_name"
+ ON "project_role" ("platformId", LOWER("name"))
+ WHERE "platformId" IS NOT NULL
+ `)
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`
+ DROP INDEX IF EXISTS "idx_project_role_platform_id_name"
+ `)
+ }
+}
diff --git a/packages/server/api/src/app/database/postgres-connection.ts b/packages/server/api/src/app/database/postgres-connection.ts
index 4ce9bc834ea9..ef4639141ad2 100644
--- a/packages/server/api/src/app/database/postgres-connection.ts
+++ b/packages/server/api/src/app/database/postgres-connection.ts
@@ -442,6 +442,7 @@ import { NullOrphanFolderReferences1846000000000 } from './migration/postgres/18
import { IndexWaitpointLookups1847000000000 } from './migration/postgres/1847000000000-IndexWaitpointLookups'
import { AddAgentConversationAiCredits1850000000000 } from './migration/postgres/1850000000000-AddAgentConversationAiCredits'
import { AddMcpOAuthCodeNonce1851000000000 } from './migration/postgres/1851000000000-AddMcpOAuthCodeNonce'
+import { UniqueProjectRoleNamePerPlatform1852000000000 } from './migration/postgres/1852000000000-UniqueProjectRoleNamePerPlatform'
import { FlowExternalIdUniqueIgnoresDeleting1855000000000 } from './migration/postgres/1855000000000-FlowExternalIdUniqueIgnoresDeleting'
import { AddFlowTombstoneIndex1856000000000 } from './migration/postgres/1856000000000-AddFlowTombstoneIndex'
import { AddWaitpointDeadLetteredAt1857000000000 } from './migration/postgres/1857000000000-AddWaitpointDeadLetteredAt'
@@ -902,6 +903,7 @@ export const getMigrations = (): (new () => Migration)[] => {
IndexWaitpointLookups1847000000000,
AddAgentConversationAiCredits1850000000000,
AddMcpOAuthCodeNonce1851000000000,
+ UniqueProjectRoleNamePerPlatform1852000000000,
FlowExternalIdUniqueIgnoresDeleting1855000000000,
AddFlowTombstoneIndex1856000000000,
AddWaitpointDeadLetteredAt1857000000000,
diff --git a/packages/server/api/src/app/ee/agent/agent-file-utils.ts b/packages/server/api/src/app/ee/agent/agent-file-utils.ts
index 44e673f6a0a7..90fd20f908a9 100644
--- a/packages/server/api/src/app/ee/agent/agent-file-utils.ts
+++ b/packages/server/api/src/app/ee/agent/agent-file-utils.ts
@@ -23,10 +23,11 @@ function sanitizeFileName(name: string): string {
return name.replace(/[^a-zA-Z0-9 \-_.()]/g, '_').slice(0, 255)
}
-async function persistAgentAttachments({ files, projectId, platformId, log }: {
+async function persistAgentAttachments({ files, projectId, platformId, conversationId, log }: {
files: Array<{ name: string, mimeType: string, data: string }>
projectId: string
platformId: string
+ conversationId: string
log: FastifyBaseLogger
}): Promise> {
return Promise.all(files.map(async (file) => {
@@ -39,7 +40,7 @@ async function persistAgentAttachments({ files, projectId, platformId, log }: {
type: FileType.FLOW_STEP_FILE,
fileName: sanitizeFileName(file.name),
compression: FileCompression.NONE,
- metadata: { mimetype: file.mimeType },
+ metadata: { mimetype: file.mimeType, conversationId },
})
return { fileId: saved.id, name: sanitizeFileName(file.name), mimeType: file.mimeType }
}))
diff --git a/packages/server/api/src/app/ee/agent/agent-helpers.ts b/packages/server/api/src/app/ee/agent/agent-helpers.ts
index cfa1b87ab81e..53e971d23019 100644
--- a/packages/server/api/src/app/ee/agent/agent-helpers.ts
+++ b/packages/server/api/src/app/ee/agent/agent-helpers.ts
@@ -1,7 +1,8 @@
+import { setTimeout as delay } from 'node:timers/promises'
import { ExecuteAgentRunJobData } from '@activepieces/core-execution'
import { ActivepiecesAiBilling, ActivepiecesError, AIProviderName, apId, assertNotNullOrUndefined, ErrorCode, isNil, spreadIfDefined, tryCatch, tryCatchSync, unique } from '@activepieces/core-utils'
import { aiUtils } from '@activepieces/server-utils'
-import { AgentConfig, AgentConversation, AgentConversationStatus, AgentFlowTool, AgentTool, AgentToolType, AI_PROVIDER_ENTITY_TYPES, AIProviderModelType, FlowVersionState, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, ResolvedAgentFlowTool, UserMemory } from '@activepieces/shared'
+import { AgentConfig, AgentConversation, AgentConversationStatus, AgentFlowTool, AgentTool, AgentToolType, AI_PROVIDER_CAPABILITIES, AI_PROVIDER_ENTITY_TYPES, AIProviderModelType, FlowVersionState, GetAgentMemoryResponse, GetProviderConfigResponse, Project, ProjectType, ResolvedAgentFlowTool, UserMemory } from '@activepieces/shared'
import { SharedV3ProviderOptions } from '@ai-sdk/provider'
import { EmbeddingModel, LanguageModel } from 'ai'
import { FastifyBaseLogger } from 'fastify'
@@ -37,6 +38,7 @@ const userMemoryRepo = repoFactory(UserMemoryEntity)
const MAX_MEMORIES = 50
const MAX_MEMORY_LENGTH = 280
const MAX_INSTRUCTIONS_LENGTH = 4000
+const IMAGE_MODEL_LOOKUP_TIMEOUT_MS = 3_000
async function getConversationOrThrow({ id, platformId, userId, log }: { id: string, platformId: string, userId: string, log?: FastifyBaseLogger }): Promise {
const conversation = await conversationRepo().findOneBy({ id, platformId, userId })
@@ -175,6 +177,33 @@ async function resolveModelId({ platformId, providerConfig, selectedModel, scope
return picked.id
}
+async function resolveImageModelId({ platformId, providerConfig, scope, log }: { platformId: string, providerConfig: GetProviderConfigResponse, scope: ProviderScope, log: FastifyBaseLogger }): Promise {
+ const { provider, configId } = providerConfig
+ const preferred = AI_PROVIDER_CAPABILITIES[provider].defaultImageModel
+ if (isNil(preferred) || provider === AIProviderName.ACTIVEPIECES) {
+ return preferred
+ }
+ const listed = await Promise.race([
+ tryCatch(() => aiProviderService(log).listModels({ platformId, provider, scope, configId })),
+ delay(IMAGE_MODEL_LOOKUP_TIMEOUT_MS, null),
+ ])
+ if (isNil(listed) || isNil(listed.data)) {
+ log.warn({ error: listed?.error, provider }, '[agentHelpers#resolveImageModelId] Could not list the key\'s models in time, leaving image generation off')
+ return undefined
+ }
+ const versionAt = preferred.search(/\d/)
+ const family = versionAt === -1 ? preferred : preferred.slice(0, versionAt)
+ const imageModelIds = listed.data.filter((model) => model.type === AIProviderModelType.IMAGE).map((model) => model.id)
+ if (imageModelIds.includes(preferred)) {
+ return preferred
+ }
+ const fallback = imageModelIds.find((id) => id.startsWith(family))
+ if (!isNil(fallback)) {
+ log.warn({ provider, preferred, fallback }, '[agentHelpers#resolveImageModelId] Default image model is not offered by this key, falling back')
+ }
+ return fallback
+}
+
async function resolveFastModelId({ platformId, providerConfig, scope, fallbackModelId, log }: { platformId: string, providerConfig: GetProviderConfigResponse, scope: ProviderScope, fallbackModelId?: string, log: FastifyBaseLogger }): Promise {
return resolveModelId({ platformId, providerConfig, selectedModel: FAST_TIER_ID, scope, log, ...spreadIfDefined('fallbackModelId', fallbackModelId) })
}
@@ -391,6 +420,7 @@ export const agentHelpers = {
...agentModelResolution,
resolveFastModel,
resolveFastModelId,
+ resolveImageModelId,
resolveModelId,
resolveRunProvider,
resolveEmbeddingModel,
diff --git a/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts b/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts
index 07a581023079..fa858bf7d0e1 100644
--- a/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts
+++ b/packages/server/api/src/app/ee/agent/prompt/agent-surface-notes.ts
@@ -2,7 +2,7 @@ import { isNil } from '@activepieces/core-utils'
import { AgentRunSource } from '@activepieces/shared'
import { agentUserIdentity, UserIdentity } from './agent-user-identity'
-function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fetchAvailable, scrapeAvailable, imageAvailable, emailAvailable, agentsAvailable, userEmail, userIdentity, connections, memory }: {
+function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fetchAvailable, scrapeAvailable, imageAvailable, imageEditAvailable, emailAvailable, agentsAvailable, userEmail, userIdentity, connections, memory }: {
source: AgentRunSource
messageSource?: 'onboarding'
currentDate: string
@@ -10,6 +10,7 @@ function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fe
fetchAvailable: boolean
scrapeAvailable: boolean
imageAvailable: boolean
+ imageEditAvailable: boolean
emailAvailable: boolean
agentsAvailable: boolean
userEmail: string
@@ -26,6 +27,7 @@ function buildRunNotes({ source, messageSource, currentDate, searchAvailable, fe
fetchAvailable: fetchAvailable && readsTheWeb,
scrapeAvailable: scrapeAvailable && readsTheWeb,
imageAvailable: imageAvailable && source !== AgentRunSource.FLOW_STEP && readsTheWeb,
+ imageEditAvailable,
emailAvailable: emailAvailable && isChat,
userEmail,
})
@@ -65,12 +67,13 @@ const ONBOARDING_FIRST_MESSAGE_NOTE = [
'Close with one line proposing the single most useful first thing you would start right now.',
].join('\n')
-function buildCapabilitiesNote({ currentDate, searchAvailable, fetchAvailable, scrapeAvailable, imageAvailable, emailAvailable, userEmail }: {
+function buildCapabilitiesNote({ currentDate, searchAvailable, fetchAvailable, scrapeAvailable, imageAvailable, imageEditAvailable, emailAvailable, userEmail }: {
currentDate: string
searchAvailable: boolean
fetchAvailable: boolean
scrapeAvailable: boolean
imageAvailable: boolean
+ imageEditAvailable: boolean
emailAvailable: boolean
userEmail: string
}): string {
@@ -96,7 +99,7 @@ function buildCapabilitiesNote({ currentDate, searchAvailable, fetchAvailable, s
}
if (imageAvailable) {
- lines.push('- **Image generation** (`ap_generate_image`): create images from a text prompt. Choose `style`: "realistic" for photos, "graphic_text" for social/email/marketing graphics with readable text, "brand_vector" for logos/icons/vector graphics, "abstract" for artistic/background images. Pass a short, fun, task-specific `caption` for the card. The image is shown to the user automatically — never paste the image URL into your reply.')
+ lines.push('- **Image generation** (`ap_generate_image`): create images from a text prompt. Choose `style`: "realistic" for photos, "graphic_text" for social/email/marketing graphics with readable text, "brand_vector" for logos/icons/vector graphics, "abstract" for artistic/background images. Pass a short, fun, task-specific `caption` for the card. ' + (imageEditAvailable ? IMAGE_EDIT_NOTE : IMAGE_NO_EDIT_NOTE) + ' The image is shown to the user automatically — never paste the image URL into your reply.')
}
if (emailAvailable) {
@@ -187,6 +190,8 @@ type RunMemory = {
memories: string[]
}
+const IMAGE_EDIT_NOTE = 'When the user asks to change an image already in the conversation (one you generated, or one they attached), pass its `fileId` as `editFileId` and describe only the change; do not regenerate it from scratch.'
+const IMAGE_NO_EDIT_NOTE = 'The configured image service can only create new images: if the user asks to change an existing one, say so and offer to generate a new image instead.'
const CAPABILITIES_HEADING = '## Capabilities (current session)'
const CONNECTED_APPS_HEADING = '## Your connected apps (this project)'
const MEMORY_HEADING = '## Memory about this user (persists across every conversation)'
diff --git a/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts b/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts
index 44d66fb08f5e..d1dd68960655 100644
--- a/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts
+++ b/packages/server/api/src/app/ee/agent/rpc/agent-config-rpc.ts
@@ -87,7 +87,7 @@ export const agentConfigRpc = (log: FastifyBaseLogger) => ({
const providerConfig = await agentHelpers.resolveRunProvider({ platformId, log, scope: runScope, ...spreadIfDefined('provider', input.provider), ...spreadIfDefined('providerConfigId', input.providerConfigId) })
const attachmentRefs = files && files.length > 0 && !isNil(selectedProjectId)
- ? await persistAgentAttachments({ files, projectId: selectedProjectId, platformId, log })
+ ? await persistAgentAttachments({ files, projectId: selectedProjectId, platformId, conversationId, log })
: []
const userContent = await buildUserContentWithFiles({ text: userMessage, files, attachmentNote: buildAttachmentNote(attachmentRefs) })
@@ -99,6 +99,8 @@ export const agentConfigRpc = (log: FastifyBaseLogger) => ({
// Tavily takes precedence over native LLM search; native is only the no-Tavily fallback.
const tavilySearchAvailable = !isNil(aiTools.webSearch)
const webSearchAvailable = fetchAvailable && (tavilySearchAvailable || aiUtils.supportsWebSearch(providerConfig.provider))
+ const generatesImagesOnProvider = actingRun && isNil(aiTools.imageGeneration)
+ const imageModelId = generatesImagesOnProvider ? await agentHelpers.resolveImageModelId({ platformId, providerConfig, scope: runScope, log }) : undefined
const lockResult = await agentHelpers.conversationRepo()
.createQueryBuilder()
@@ -163,7 +165,8 @@ export const agentConfigRpc = (log: FastifyBaseLogger) => ({
searchAvailable: webSearchAvailable,
fetchAvailable,
scrapeAvailable: fetchAvailable && !isNil(aiTools.webScraping),
- imageAvailable: actingRun && !isNil(aiTools.imageGeneration),
+ imageAvailable: actingRun && (!isNil(aiTools.imageGeneration) || !isNil(imageModelId)),
+ imageEditAvailable: !isNil(imageModelId),
emailAvailable: emailEnabled,
agentsAvailable,
userEmail: runUserEmail,
@@ -243,6 +246,7 @@ export const agentConfigRpc = (log: FastifyBaseLogger) => ({
providerConfigId: providerConfig.configId,
modelId: resolvedModelId,
fastModelId,
+ ...spreadIfDefined('imageModelId', imageModelId),
systemPrompt: systemPromptText,
messages: messagesForLlm,
allMessages,
diff --git a/packages/server/api/src/app/ee/agent/rpc/conversation-rpc.ts b/packages/server/api/src/app/ee/agent/rpc/conversation-rpc.ts
index f70f6e6db652..6b06592780fa 100644
--- a/packages/server/api/src/app/ee/agent/rpc/conversation-rpc.ts
+++ b/packages/server/api/src/app/ee/agent/rpc/conversation-rpc.ts
@@ -1,5 +1,5 @@
import { ActivepiecesError, ErrorCode, isNil, sanitizeObjectForPostgresql, spreadIfDefined } from '@activepieces/core-utils'
-import { AgentConversationStatus, AgentRunSource, FileCompression, FileType, HeartbeatAgentConversationRequest, SaveAgentFileRequest, SaveAgentFileResponse, SaveAgentMessagesRequest, UpdateAgentProgressRequest, UpdateProjectContextRequest } from '@activepieces/shared'
+import { AgentConversationStatus, AgentRunSource, FileCompression, FileType, HeartbeatAgentConversationRequest, ReadAgentFileRequest, ReadFlowStepFileResponse, SaveAgentFileRequest, SaveAgentFileResponse, SaveAgentMessagesRequest, UpdateAgentProgressRequest, UpdateProjectContextRequest } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
import { agentHelpers } from '.././agent-helpers'
import { chatAnalyticsTelemetry } from '.././chat-analytics-sync'
@@ -26,7 +26,7 @@ export const conversationRpc = (log: FastifyBaseLogger) => ({
type: FileType.FLOW_STEP_FILE,
fileName: input.fileName,
compression: FileCompression.NONE,
- metadata: { mimetype: input.mediaType },
+ metadata: { mimetype: input.mediaType, conversationId: input.conversationId },
})
const url = await filesService.constructReadUrl({
fileId: file.id,
@@ -36,6 +36,18 @@ export const conversationRpc = (log: FastifyBaseLogger) => ({
return { fileId: file.id, url }
},
+ async readAgentFile(input: ReadAgentFileRequest): Promise {
+ const file = await fileService(log).getDataOrThrow({ platformId: input.platformId, fileId: input.fileId, type: FileType.FLOW_STEP_FILE })
+ if (file.metadata?.['conversationId'] !== input.conversationId) {
+ throw new ActivepiecesError({ code: ErrorCode.ENTITY_NOT_FOUND, params: { entityType: 'file', entityId: input.fileId } })
+ }
+ return {
+ data: file.data,
+ ...spreadIfDefined('mimeType', file.metadata?.['mimetype']),
+ ...spreadIfDefined('fileName', file.fileName),
+ }
+ },
+
async saveAgentMessages(input: SaveAgentMessagesRequest): Promise {
const isSuccessfulCompletion = input.messages.length > 0
const updates: Record = {
diff --git a/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts b/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts
index c4feaf4ffe2c..c693ac2f68ec 100644
--- a/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts
+++ b/packages/server/api/src/app/ee/authentication/project-role/rbac-middleware.ts
@@ -8,7 +8,9 @@ import { system } from '../../../helper/system/system'
import { projectMemberService } from '../../projects/project-members/project-member.service'
import { projectRoleService } from '../../projects/project-role/project-role.service'
-const EDITION_IS_COMMUNITY = system.getEdition() === ApEdition.COMMUNITY
+export function editionRequiresRbac(): boolean {
+ return [ApEdition.CLOUD, ApEdition.ENTERPRISE].includes(system.getEdition())
+}
export const rbacMiddleware = async (req: FastifyRequest): Promise => {
if (ignoreRequest(req)) {
@@ -28,8 +30,7 @@ export async function assertUserHasPermissionToFlow(
operationType: FlowOperationType,
log: FastifyBaseLogger,
): Promise {
- const edition = system.getEdition()
- if (![ApEdition.CLOUD, ApEdition.ENTERPRISE].includes(edition)) {
+ if (!editionRequiresRbac()) {
return
}
@@ -85,7 +86,7 @@ export const assertRoleHasPermission = async (principal: Principal, projectId: P
}
const ignoreRequest = (req: FastifyRequest): boolean => {
- if (EDITION_IS_COMMUNITY) {
+ if (!editionRequiresRbac()) {
return true
}
const ignoredPrefixes = ['/redirect', '/ui', '/api/ui']
diff --git a/packages/server/api/src/app/ee/pieces/piece-set/piece-set.service.ts b/packages/server/api/src/app/ee/pieces/piece-set/piece-set.service.ts
index a8389a37d70f..cbba6d32e111 100644
--- a/packages/server/api/src/app/ee/pieces/piece-set/piece-set.service.ts
+++ b/packages/server/api/src/app/ee/pieces/piece-set/piece-set.service.ts
@@ -1,9 +1,10 @@
import { ActivepiecesError, apId, ErrorCode, isNil, kebabCase, SeekPage, spreadIfDefined, tryCatch } from '@activepieces/core-utils'
import { CreatePieceSetRequestBody, PieceSet, PieceSetConfig, UpdatePieceSetRequestBody } from '@activepieces/shared'
import { FastifyBaseLogger } from 'fastify'
-import { EntityManager, In, QueryFailedError } from 'typeorm'
+import { EntityManager, In } from 'typeorm'
import { repoFactory } from '../../../core/db/repo-factory'
import { transaction } from '../../../core/db/transaction'
+import { isUniqueViolation } from '../../../core/db/unique-violation'
import { distributedLock } from '../../../database/redis-connections'
import { buildPaginator } from '../../../helper/pagination/build-paginator'
import { paginationHelper } from '../../../helper/pagination/pagination-utils'
@@ -220,8 +221,7 @@ function resolveKey({ key, name }: { key?: string | null, name: string }): strin
}
function rethrowKeyConflict(error: unknown): never {
- const driverError: unknown = error instanceof QueryFailedError ? error.driverError : undefined
- if (typeof driverError === 'object' && driverError !== null && 'code' in driverError && driverError.code === '23505') {
+ if (isUniqueViolation(error)) {
throw new ActivepiecesError({
code: ErrorCode.VALIDATION,
params: { message: 'Piece set key already used' },
diff --git a/packages/server/api/src/app/ee/projects/project-role/project-role.entity.ts b/packages/server/api/src/app/ee/projects/project-role/project-role.entity.ts
index 71c76df74be2..e4918575436a 100644
--- a/packages/server/api/src/app/ee/projects/project-role/project-role.entity.ts
+++ b/packages/server/api/src/app/ee/projects/project-role/project-role.entity.ts
@@ -12,6 +12,15 @@ export type ProjectRoleSchema = ProjectRole & {
export const ProjectRoleEntity = new EntitySchema({
name: 'project_role',
+ indices: [
+ {
+ name: 'idx_project_role_platform_id_name',
+ columns: ['platformId', 'name'],
+ unique: true,
+ where: '"platformId" IS NOT NULL',
+ synchronize: false,
+ },
+ ],
columns: {
...BaseColumnSchemaPart,
name: {
diff --git a/packages/server/api/src/app/ee/projects/project-role/project-role.service.ts b/packages/server/api/src/app/ee/projects/project-role/project-role.service.ts
index 346680f99c1f..b09c8f5b6a09 100644
--- a/packages/server/api/src/app/ee/projects/project-role/project-role.service.ts
+++ b/packages/server/api/src/app/ee/projects/project-role/project-role.service.ts
@@ -1,7 +1,8 @@
-import { ActivepiecesError, apId, ApId, ErrorCode, isNil, PlatformId, ProjectRole, RoleType, SeekPage, spreadIfDefined } from '@activepieces/core-utils'
+import { ActivepiecesError, apId, ApId, ErrorCode, isNil, PlatformId, ProjectRole, RoleType, SeekPage, spreadIfDefined, tryCatch } from '@activepieces/core-utils'
import { CreateProjectRoleRequestBody } from '@activepieces/shared'
import { Brackets, Equal } from 'typeorm'
import { repoFactory } from '../../../core/db/repo-factory'
+import { isUniqueViolation } from '../../../core/db/unique-violation'
import { ProjectMemberEntity } from '../project-members/project-member.entity'
import { ProjectRoleEntity } from './project-role.entity'
@@ -74,27 +75,38 @@ export const projectRoleService = {
platformId,
})
if (projectRoleExists) {
- throw new ActivepiecesError({
- code: ErrorCode.ENTITY_NOT_FOUND,
- params: { entityType: 'project_role', entityId: params.name, message: 'Project Role name already exists' },
- })
+ throw nameAlreadyExists(params.name)
}
- return projectRoleRepo().save({
- id: apId(),
+ const id = apId()
+ const { error } = await tryCatch(() => projectRoleRepo().save({
+ id,
platformId,
...params,
- })
+ }))
+ if (error) {
+ rethrowNameConflict({ error, name: params.name })
+ }
+ return projectRoleRepo().findOneByOrFail({ id })
},
async update(params: UpdateParams): Promise {
- await projectRoleRepo().update({
+ if (!isNil(params.name)) {
+ const existing = await this.getOne({ name: params.name, platformId: params.platformId })
+ if (!isNil(existing) && existing.id !== params.id) {
+ throw nameAlreadyExists(params.name)
+ }
+ }
+ const { error } = await tryCatch(() => projectRoleRepo().update({
id: params.id,
platformId: params.platformId,
}, {
...spreadIfDefined('name', params.name),
...spreadIfDefined('permissions', params.permissions),
- })
+ }))
+ if (error) {
+ rethrowNameConflict({ error, name: params.name })
+ }
return projectRoleRepo().findOneByOrFail({ id: params.id, platformId: params.platformId })
},
@@ -103,6 +115,25 @@ export const projectRoleService = {
},
}
+function nameAlreadyExists(name: string | undefined): ActivepiecesError {
+ return new ActivepiecesError({
+ code: ErrorCode.VALIDATION,
+ params: { message: `Project role name already exists: ${name}` },
+ })
+}
+
+function rethrowNameConflict({ error, name }: RethrowNameConflictParams): never {
+ if (isUniqueViolation(error)) {
+ throw nameAlreadyExists(name)
+ }
+ throw error
+}
+
+type RethrowNameConflictParams = {
+ error: unknown
+ name: string | undefined
+}
+
type UpdateParams = {
id: ApId
name: string | undefined
diff --git a/packages/server/api/src/app/file/file.service.ts b/packages/server/api/src/app/file/file.service.ts
index 1cd052213550..96b7893a82fe 100644
--- a/packages/server/api/src/app/file/file.service.ts
+++ b/packages/server/api/src/app/file/file.service.ts
@@ -121,9 +121,10 @@ export const fileService = (log: FastifyBaseLogger) => ({
}
},
- async getDataOrThrow({ projectId, fileId, type }: GetOneParams): Promise {
+ async getDataOrThrow({ projectId, platformId, fileId, type }: GetOneParams): Promise {
const file = await fileRepo().findOneBy({
projectId,
+ platformId,
id: fileId,
type: normalizeTypeFilter(type),
})
@@ -408,6 +409,7 @@ type SaveParams = {
type GetOneParams = {
fileId?: FileId
projectId?: ProjectId
+ platformId?: string
type?: FileType | FileType[]
}
diff --git a/packages/server/api/src/app/mcp/mcp-access.ts b/packages/server/api/src/app/mcp/mcp-access.ts
new file mode 100644
index 000000000000..92fb8792c791
--- /dev/null
+++ b/packages/server/api/src/app/mcp/mcp-access.ts
@@ -0,0 +1,83 @@
+import { isNil, Permission } from '@activepieces/core-utils'
+import { McpReachResponse, McpToolResult, Project, ProjectType } from '@activepieces/shared'
+import { FastifyBaseLogger } from 'fastify'
+import { editionRequiresRbac } from '../ee/authentication/project-role/rbac-middleware'
+import { projectMemberService } from '../ee/projects/project-members/project-member.service'
+import { projectService } from '../project/project-service'
+import { userService } from '../user/user-service'
+import { deny } from './mcp-permissions'
+
+export const mcpAccess = {
+ listAccessibleProjects,
+ resolveReach,
+ hasMcpReach,
+ hasMcpAccessToProject,
+ noMcpReachResult,
+}
+
+async function listAccessibleProjects({ platformId, userId, log }: UserScope): Promise