mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			21 Commits
		
	
	
		
			550e74e559
			...
			fix-chore-
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|   | 770fb2d60e | ||
|   | c8ef9e663a | ||
|   | 2195e4af45 | ||
|   | c6716905a4 | ||
|   | 850ee5a415 | ||
|   | b25b5abdb0 | ||
|   | 68e0559053 | ||
|   | 4ff09c4cf4 | ||
|   | 53b393dab5 | ||
|   | 6119c215e7 | ||
|   | 8d1f23e9d6 | ||
|   | c8850fa752 | ||
|   | 19a54b3b23 | ||
|   | 1cdd8d9ba8 | ||
|   | 4449dbadb5 | ||
|   | 43b4f36026 | ||
|   | 0e35acaef5 | ||
|   | 19ff339804 | ||
|   | 6b868a5ecb | ||
|   | 3e4aa87cc5 | ||
|   | fc95d42b35 | 
							
								
								
									
										430
									
								
								.github/workflows/build-and-release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										430
									
								
								.github/workflows/build-and-release.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,430 @@ | ||||
| name: 'Build and Release' | ||||
| on: | ||||
|   workflow_run: | ||||
|     workflows: | ||||
|       - ci | ||||
|     types: | ||||
|       - completed | ||||
| permissions: | ||||
|   contents: write | ||||
|   packages: write | ||||
|   pull-requests: write | ||||
| env: | ||||
|   DEFAULT_UV_VERSION: "0.8.x" | ||||
|   DEFAULT_PYTHON_VERSION: "3.11" | ||||
|   NLTK_DATA: "/usr/share/nltk_data" | ||||
| jobs: | ||||
|   prepare: | ||||
|     if: >- | ||||
|       github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' | ||||
|     name: Prepare build context | ||||
|     runs-on: ubuntu-24.04 | ||||
|     outputs: | ||||
|       should-build: ${{ steps.determine.outputs.should-build }} | ||||
|       ref: ${{ steps.determine.outputs.ref }} | ||||
|       ref-name: ${{ steps.determine.outputs.ref-name }} | ||||
|       sha: ${{ steps.determine.outputs.sha }} | ||||
|       is-tag: ${{ steps.determine.outputs.is-tag }} | ||||
|       is-release-target: ${{ steps.determine.outputs.is-release-target }} | ||||
|       is-beta-rc: ${{ steps.determine.outputs.is-beta-rc }} | ||||
|     steps: | ||||
|       - name: Determine ref information | ||||
|         id: determine | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const run = context.payload.workflow_run; | ||||
|             const owner = context.repo.owner; | ||||
|             const repo = context.repo.repo; | ||||
|             const sha = run.head_sha; | ||||
|             const branch = run.head_branch; | ||||
|  | ||||
|             let ref = undefined; | ||||
|             let refName = undefined; | ||||
|  | ||||
|             if (branch) { | ||||
|               ref = `refs/heads/${branch}`; | ||||
|               refName = branch; | ||||
|             } else { | ||||
|               const iterator = github.paginate.iterator( | ||||
|                 github.rest.repos.listTags, | ||||
|                 { | ||||
|                   owner, | ||||
|                   repo, | ||||
|                   per_page: 100, | ||||
|                 }, | ||||
|               ); | ||||
|  | ||||
|               for await (const { data } of iterator) { | ||||
|                 const match = data.find((tag) => tag.commit?.sha === sha); | ||||
|                 if (match) { | ||||
|                   ref = `refs/tags/${match.name}`; | ||||
|                   refName = match.name; | ||||
|                   break; | ||||
|                 } | ||||
|               } | ||||
|             } | ||||
|  | ||||
|             const outputs = { | ||||
|               shouldBuild: false, | ||||
|               ref: ref ?? '', | ||||
|               refName: refName ?? '', | ||||
|               sha, | ||||
|               isTag: ref?.startsWith('refs/tags/') ?? false, | ||||
|               isReleaseTarget: false, | ||||
|               isBetaRc: false, | ||||
|             }; | ||||
|  | ||||
|             if (!ref || !refName) { | ||||
|               core.info('No matching ref found for workflow run; skipping post-CI workflow.'); | ||||
|             } else { | ||||
|               const allowed = | ||||
|                 ref.startsWith('refs/heads/feature-') || | ||||
|                 ref.startsWith('refs/heads/fix-') || | ||||
|                 ref.startsWith('refs/heads/l10n_') || | ||||
|                 ref === 'refs/heads/dev' || | ||||
|                 ref === 'refs/heads/beta' || | ||||
|                 ref.includes('beta.rc') || | ||||
|                 ref.startsWith('refs/tags/v'); | ||||
|  | ||||
|               const isBetaRc = refName.includes('beta.rc'); | ||||
|               const isReleaseTarget = outputs.isTag && (refName.startsWith('v') || isBetaRc); | ||||
|  | ||||
|               outputs.shouldBuild = allowed; | ||||
|               outputs.isReleaseTarget = isReleaseTarget; | ||||
|               outputs.isBetaRc = isBetaRc; | ||||
|             } | ||||
|  | ||||
|             core.setOutput('should-build', outputs.shouldBuild ? 'true' : 'false'); | ||||
|             core.setOutput('ref', outputs.ref); | ||||
|             core.setOutput('ref-name', outputs.refName); | ||||
|             core.setOutput('sha', outputs.sha); | ||||
|             core.setOutput('is-tag', outputs.isTag ? 'true' : 'false'); | ||||
|             core.setOutput('is-release-target', outputs.isReleaseTarget ? 'true' : 'false'); | ||||
|             core.setOutput('is-beta-rc', outputs.isBetaRc ? 'true' : 'false'); | ||||
|   build-docker-image: | ||||
|     needs: prepare | ||||
|     if: needs.prepare.outputs.should-build == 'true' | ||||
|     name: Build Docker image for ${{ needs.prepare.outputs.ref-name }} | ||||
|     runs-on: ubuntu-24.04 | ||||
|     concurrency: | ||||
|       group: ${{ github.workflow }}-build-docker-image-${{ needs.prepare.outputs.ref-name || needs.prepare.outputs.sha }} | ||||
|       cancel-in-progress: true | ||||
|     env: | ||||
|       REF: ${{ needs.prepare.outputs.ref }} | ||||
|       REF_NAME: ${{ needs.prepare.outputs.ref-name }} | ||||
|       SHA: ${{ needs.prepare.outputs.sha }} | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           ref: ${{ env.SHA }} | ||||
|       - name: Check pushing to Docker Hub | ||||
|         id: push-other-places | ||||
|         env: | ||||
|           REPOSITORY_OWNER: ${{ github.repository_owner }} | ||||
|           REF_NAME: ${{ env.REF_NAME }} | ||||
|           REF: ${{ env.REF }} | ||||
|         run: | | ||||
|           if [[ "$REPOSITORY_OWNER" == "paperless-ngx" ]] && \ | ||||
|              ([[ "$REF_NAME" == "dev" ]] || [[ "$REF_NAME" == "beta" ]] || [[ "$REF" == refs/tags/v* ]]); then | ||||
|             echo "Enabling DockerHub image push" | ||||
|             echo "enable=true" >> "$GITHUB_OUTPUT" | ||||
|           else | ||||
|             echo "Not pushing to DockerHub" | ||||
|             echo "enable=false" >> "$GITHUB_OUTPUT" | ||||
|           fi | ||||
|       - name: Set ghcr repository name | ||||
|         id: set-ghcr-repository | ||||
|         run: | | ||||
|           ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }') | ||||
|           echo "Name is ${ghcr_name}" | ||||
|           echo "ghcr-repository=${ghcr_name}" >> "$GITHUB_OUTPUT" | ||||
|       - name: Gather Docker metadata | ||||
|         id: docker-meta | ||||
|         uses: docker/metadata-action@v5 | ||||
|         env: | ||||
|           GITHUB_REF: ${{ env.REF }} | ||||
|           GITHUB_REF_NAME: ${{ env.REF_NAME }} | ||||
|           GITHUB_SHA: ${{ env.SHA }} | ||||
|         with: | ||||
|           images: | | ||||
|             ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }} | ||||
|             name=paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }} | ||||
|             name=quay.io/paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }} | ||||
|           tags: | | ||||
|             type=ref,event=branch | ||||
|             type=semver,pattern={{version}} | ||||
|             type=semver,pattern={{major}}.{{minor}} | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v3 | ||||
|         with: | ||||
|           platforms: arm64 | ||||
|       - name: Login to GitHub Container Registry | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Login to Docker Hub | ||||
|         if: steps.push-other-places.outputs.enable == 'true' | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|       - name: Login to Quay.io | ||||
|         if: steps.push-other-places.outputs.enable == 'true' | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: quay.io | ||||
|           username: ${{ secrets.QUAY_USERNAME }} | ||||
|           password: ${{ secrets.QUAY_ROBOT_TOKEN }} | ||||
|       - name: Build and push | ||||
|         uses: docker/build-push-action@v6 | ||||
|         with: | ||||
|           context: . | ||||
|           file: ./Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           push: true | ||||
|           tags: ${{ steps.docker-meta.outputs.tags }} | ||||
|           labels: ${{ steps.docker-meta.outputs.labels }} | ||||
|           build-args: | | ||||
|             PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }} | ||||
|           cache-from: | | ||||
|             type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ env.REF_NAME }} | ||||
|             type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:dev | ||||
|           cache-to: | | ||||
|             type=registry,mode=max,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ env.REF_NAME }} | ||||
|       - name: Inspect image | ||||
|         run: | | ||||
|           docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }} | ||||
|       - name: Export frontend artifact from docker | ||||
|         run: | | ||||
|           docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }} | ||||
|           docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/ | ||||
|       - name: Upload frontend artifact | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: frontend-compiled | ||||
|           path: src/documents/static/frontend/ | ||||
|           retention-days: 7 | ||||
|   build-release: | ||||
|     needs: | ||||
|       - prepare | ||||
|       - build-docker-image | ||||
|     if: needs.prepare.outputs.should-build == 'true' | ||||
|     name: Build release bundle | ||||
|     runs-on: ubuntu-24.04 | ||||
|     env: | ||||
|       REF_NAME: ${{ needs.prepare.outputs.ref-name }} | ||||
|       SHA: ${{ needs.prepare.outputs.sha }} | ||||
|       CI_RUN_ID: ${{ github.event.workflow_run.id }} | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           ref: ${{ env.SHA }} | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
|         uses: astral-sh/setup-uv@v6 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install Python dependencies | ||||
|         run: | | ||||
|           uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen | ||||
|       - name: Install system dependencies | ||||
|         run: | | ||||
|           sudo apt-get update -qq | ||||
|           sudo apt-get install -qq --no-install-recommends gettext liblept5 | ||||
|       - name: Download frontend artifact | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: frontend-compiled | ||||
|           path: src/documents/static/frontend/ | ||||
|       - name: Download documentation artifact | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: documentation | ||||
|           path: docs/_build/html/ | ||||
|           run-id: ${{ env.CI_RUN_ID }} | ||||
|       - name: Generate requirements file | ||||
|         run: | | ||||
|           uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt | ||||
|       - name: Compile messages | ||||
|         run: | | ||||
|           cd src/ | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             manage.py compilemessages | ||||
|       - name: Collect static files | ||||
|         run: | | ||||
|           cd src/ | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             manage.py collectstatic --no-input | ||||
|       - name: Move files | ||||
|         run: | | ||||
|           echo "Making dist folders" | ||||
|           for directory in dist \ | ||||
|                           dist/paperless-ngx \ | ||||
|                           dist/paperless-ngx/scripts; | ||||
|           do | ||||
|             mkdir --verbose --parents ${directory} | ||||
|           done | ||||
|  | ||||
|           echo "Copying basic files" | ||||
|           for file_name in .dockerignore \ | ||||
|                           .env \ | ||||
|                           Dockerfile \ | ||||
|                           pyproject.toml \ | ||||
|                           uv.lock \ | ||||
|                           requirements.txt \ | ||||
|                           LICENSE \ | ||||
|                           README.md \ | ||||
|                           paperless.conf.example | ||||
|           do | ||||
|             cp --verbose ${file_name} dist/paperless-ngx/ | ||||
|           done | ||||
|           mv --verbose dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf | ||||
|  | ||||
|           echo "Copying Docker related files" | ||||
|           cp --recursive docker/ dist/paperless-ngx/docker | ||||
|  | ||||
|           echo "Copying startup scripts" | ||||
|           cp --verbose scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/ | ||||
|  | ||||
|           echo "Copying source files" | ||||
|           cp --recursive src/ dist/paperless-ngx/src | ||||
|           echo "Copying documentation" | ||||
|           cp --recursive docs/_build/html/ dist/paperless-ngx/docs | ||||
|  | ||||
|           mv --verbose static dist/paperless-ngx | ||||
|       - name: Make release package | ||||
|         run: | | ||||
|           echo "Creating release archive" | ||||
|           cd dist | ||||
|           sudo chown -R 1000:1000 paperless-ngx/ | ||||
|           tar -cJf paperless-ngx.tar.xz paperless-ngx/ | ||||
|       - name: Upload release artifact | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: release | ||||
|           path: dist/paperless-ngx.tar.xz | ||||
|           retention-days: 7 | ||||
|   publish-release: | ||||
|     needs: | ||||
|       - prepare | ||||
|       - build-release | ||||
|     if: needs.prepare.outputs.is-release-target == 'true' | ||||
|     name: Publish release | ||||
|     runs-on: ubuntu-24.04 | ||||
|     outputs: | ||||
|       prerelease: ${{ steps.get_version.outputs.prerelease }} | ||||
|       changelog: ${{ steps.create-release.outputs.body }} | ||||
|       version: ${{ steps.get_version.outputs.version }} | ||||
|     steps: | ||||
|       - name: Download release artifact | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: release | ||||
|           path: ./ | ||||
|       - name: Get version | ||||
|         id: get_version | ||||
|         run: | | ||||
|           echo "version=${{ needs.prepare.outputs.ref-name }}" >> "$GITHUB_OUTPUT" | ||||
|           if [[ ${{ needs.prepare.outputs.is-beta-rc }} == 'true' ]]; then | ||||
|             echo "prerelease=true" >> "$GITHUB_OUTPUT" | ||||
|           else | ||||
|             echo "prerelease=false" >> "$GITHUB_OUTPUT" | ||||
|           fi | ||||
|       - name: Create Release and Changelog | ||||
|         id: create-release | ||||
|         uses: release-drafter/release-drafter@v6 | ||||
|         with: | ||||
|           name: Paperless-ngx ${{ steps.get_version.outputs.version }} | ||||
|           tag: ${{ steps.get_version.outputs.version }} | ||||
|           version: ${{ steps.get_version.outputs.version }} | ||||
|           prerelease: ${{ steps.get_version.outputs.prerelease }} | ||||
|           publish: true | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Upload release archive | ||||
|         id: upload-release-asset | ||||
|         uses: shogo82148/actions-upload-release-asset@v1 | ||||
|         with: | ||||
|           github_token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           upload_url: ${{ steps.create-release.outputs.upload_url }} | ||||
|           asset_path: ./paperless-ngx.tar.xz | ||||
|           asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz | ||||
|           asset_content_type: application/x-xz | ||||
|   append-changelog: | ||||
|     needs: | ||||
|       - publish-release | ||||
|     if: needs.publish-release.outputs.prerelease == 'false' | ||||
|     name: Append changelog to docs | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           ref: main | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
|         uses: astral-sh/setup-uv@v6 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Append Changelog to docs | ||||
|         id: append-Changelog | ||||
|         working-directory: docs | ||||
|         run: | | ||||
|           git branch ${{ needs.publish-release.outputs.version }}-changelog | ||||
|           git checkout ${{ needs.publish-release.outputs.version }}-changelog | ||||
|           echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md | ||||
|           echo "Manually linking usernames" | ||||
|           sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md | ||||
|           echo "Removing unneeded comment tags" | ||||
|           sed -i -r 's|@<!---->|@|g' changelog-new.md | ||||
|           CURRENT_CHANGELOG=`tail --lines +2 changelog.md` | ||||
|           echo -e "$CURRENT_CHANGELOG" >> changelog-new.md | ||||
|           mv changelog-new.md changelog.md | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             --dev \ | ||||
|             pre-commit run --files changelog.md || true | ||||
|           git config --global user.name "github-actions" | ||||
|           git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||
|           git push origin ${{ needs.publish-release.outputs.version }}-changelog | ||||
|       - name: Create Pull Request | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const { repo, owner } = context.repo; | ||||
|             const result = await github.rest.pulls.create({ | ||||
|               title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog', | ||||
|               owner, | ||||
|               repo, | ||||
|               head: '${{ needs.publish-release.outputs.version }}-changelog', | ||||
|               base: 'main', | ||||
|               body: 'This PR is auto-generated by CI.' | ||||
|             }); | ||||
|             github.rest.issues.addLabels({ | ||||
|               owner, | ||||
|               repo, | ||||
|               issue_number: result.data.number, | ||||
|               labels: ['documentation', 'skip-changelog'] | ||||
|             }); | ||||
							
								
								
									
										321
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										321
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -313,324 +313,3 @@ jobs: | ||||
|         env: | ||||
|           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | ||||
|         run: cd src-ui && pnpm run build --configuration=production | ||||
|   build-docker-image: | ||||
|     name: Build Docker image for ${{ github.ref_name }} | ||||
|     runs-on: ubuntu-24.04 | ||||
|     if: github.event_name == 'push' && (startsWith(github.ref, 'refs/heads/feature-') || startsWith(github.ref, 'refs/heads/fix-') || github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/beta' || contains(github.ref, 'beta.rc') || startsWith(github.ref, 'refs/tags/v') || startsWith(github.ref, 'refs/heads/l10n_')) | ||||
|     concurrency: | ||||
|       group: ${{ github.workflow }}-build-docker-image-${{ github.ref_name }} | ||||
|       cancel-in-progress: true | ||||
|     needs: | ||||
|       - tests-backend | ||||
|       - tests-frontend | ||||
|       - tests-frontend-e2e | ||||
|     steps: | ||||
|       - name: Check pushing to Docker Hub | ||||
|         id: push-other-places | ||||
|         # Only push to Dockerhub from the main repo AND the ref is either: | ||||
|         #  main | ||||
|         #  dev | ||||
|         #  beta | ||||
|         #  a tag | ||||
|         # Otherwise forks would require a Docker Hub account and secrets setup | ||||
|         run: | | ||||
|           if [[ ${{ github.repository_owner }} == "paperless-ngx" && ( ${{ github.ref_name }} == "dev" || ${{ github.ref_name }} == "beta" || ${{ startsWith(github.ref, 'refs/tags/v') }} == "true" ) ]] ; then | ||||
|             echo "Enabling DockerHub image push" | ||||
|             echo "enable=true" >> $GITHUB_OUTPUT | ||||
|           else | ||||
|             echo "Not pushing to DockerHub" | ||||
|             echo "enable=false" >> $GITHUB_OUTPUT | ||||
|           fi | ||||
|       - name: Set ghcr repository name | ||||
|         id: set-ghcr-repository | ||||
|         run: | | ||||
|           ghcr_name=$(echo "${{ github.repository }}" | awk '{ print tolower($0) }') | ||||
|           echo "Name is ${ghcr_name}" | ||||
|           echo "ghcr-repository=${ghcr_name}" >> $GITHUB_OUTPUT | ||||
|       - name: Gather Docker metadata | ||||
|         id: docker-meta | ||||
|         uses: docker/metadata-action@v5 | ||||
|         with: | ||||
|           images: | | ||||
|             ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }} | ||||
|             name=paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }} | ||||
|             name=quay.io/paperlessngx/paperless-ngx,enable=${{ steps.push-other-places.outputs.enable }} | ||||
|           tags: | | ||||
|             # Tag branches with branch name | ||||
|             type=ref,event=branch | ||||
|             # Process semver tags | ||||
|             # For a tag x.y.z or vX.Y.Z, output an x.y.z and x.y image tag | ||||
|             type=semver,pattern={{version}} | ||||
|             type=semver,pattern={{major}}.{{minor}} | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v5 | ||||
|       # If https://github.com/docker/buildx/issues/1044 is resolved, | ||||
|       # the append input with a native arm64 arch could be used to | ||||
|       # significantly speed up building | ||||
|       - name: Set up Docker Buildx | ||||
|         uses: docker/setup-buildx-action@v3 | ||||
|       - name: Set up QEMU | ||||
|         uses: docker/setup-qemu-action@v3 | ||||
|         with: | ||||
|           platforms: arm64 | ||||
|       - name: Login to GitHub Container Registry | ||||
|         uses: docker/login-action@v3 | ||||
|         with: | ||||
|           registry: ghcr.io | ||||
|           username: ${{ github.actor }} | ||||
|           password: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Login to Docker Hub | ||||
|         uses: docker/login-action@v3 | ||||
|         # Don't attempt to login if not pushing to Docker Hub | ||||
|         if: steps.push-other-places.outputs.enable == 'true' | ||||
|         with: | ||||
|           username: ${{ secrets.DOCKERHUB_USERNAME }} | ||||
|           password: ${{ secrets.DOCKERHUB_TOKEN }} | ||||
|       - name: Login to Quay.io | ||||
|         uses: docker/login-action@v3 | ||||
|         # Don't attempt to login if not pushing to Quay.io | ||||
|         if: steps.push-other-places.outputs.enable == 'true' | ||||
|         with: | ||||
|           registry: quay.io | ||||
|           username: ${{ secrets.QUAY_USERNAME }} | ||||
|           password: ${{ secrets.QUAY_ROBOT_TOKEN }} | ||||
|       - name: Build and push | ||||
|         uses: docker/build-push-action@v6 | ||||
|         with: | ||||
|           context: . | ||||
|           file: ./Dockerfile | ||||
|           platforms: linux/amd64,linux/arm64 | ||||
|           push: ${{ github.event_name != 'pull_request' }} | ||||
|           tags: ${{ steps.docker-meta.outputs.tags }} | ||||
|           labels: ${{ steps.docker-meta.outputs.labels }} | ||||
|           build-args: | | ||||
|             PNGX_TAG_VERSION=${{ steps.docker-meta.outputs.version }} | ||||
|           # Get cache layers from this branch, then dev | ||||
|           # This allows new branches to get at least some cache benefits, generally from dev | ||||
|           cache-from: | | ||||
|             type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }} | ||||
|             type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:dev | ||||
|           cache-to: | | ||||
|             type=registry,mode=max,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }} | ||||
|       - name: Inspect image | ||||
|         run: | | ||||
|           docker buildx imagetools inspect ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }} | ||||
|       - name: Export frontend artifact from docker | ||||
|         run: | | ||||
|           docker create --name frontend-extract ${{ fromJSON(steps.docker-meta.outputs.json).tags[0] }} | ||||
|           docker cp frontend-extract:/usr/src/paperless/src/documents/static/frontend src/documents/static/frontend/ | ||||
|       - name: Upload frontend artifact | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: frontend-compiled | ||||
|           path: src/documents/static/frontend/ | ||||
|           retention-days: 7 | ||||
|   build-release: | ||||
|     name: "Build Release" | ||||
|     needs: | ||||
|       - build-docker-image | ||||
|       - documentation | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v5 | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
|         uses: astral-sh/setup-uv@v6 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ steps.setup-python.outputs.python-version }} | ||||
|       - name: Install Python dependencies | ||||
|         run: | | ||||
|           uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen | ||||
|       - name: Install system dependencies | ||||
|         run: | | ||||
|           sudo apt-get update -qq | ||||
|           sudo apt-get install -qq --no-install-recommends gettext liblept5 | ||||
|       - name: Download frontend artifact | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: frontend-compiled | ||||
|           path: src/documents/static/frontend/ | ||||
|       - name: Download documentation artifact | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: documentation | ||||
|           path: docs/_build/html/ | ||||
|       - name: Generate requirements file | ||||
|         run: | | ||||
|           uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt | ||||
|       - name: Compile messages | ||||
|         run: | | ||||
|           cd src/ | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             manage.py compilemessages | ||||
|       - name: Collect static files | ||||
|         run: | | ||||
|           cd src/ | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             manage.py collectstatic --no-input | ||||
|       - name: Move files | ||||
|         run: | | ||||
|           echo "Making dist folders" | ||||
|           for directory in dist \ | ||||
|                           dist/paperless-ngx \ | ||||
|                           dist/paperless-ngx/scripts; | ||||
|           do | ||||
|             mkdir --verbose --parents ${directory} | ||||
|           done | ||||
|  | ||||
|           echo "Copying basic files" | ||||
|           for file_name in .dockerignore \ | ||||
|                           .env \ | ||||
|                           Dockerfile \ | ||||
|                           pyproject.toml \ | ||||
|                           uv.lock \ | ||||
|                           requirements.txt \ | ||||
|                           LICENSE \ | ||||
|                           README.md \ | ||||
|                           paperless.conf.example | ||||
|           do | ||||
|             cp --verbose ${file_name} dist/paperless-ngx/ | ||||
|           done | ||||
|           mv --verbose dist/paperless-ngx/paperless.conf.example dist/paperless-ngx/paperless.conf | ||||
|  | ||||
|           echo "Copying Docker related files" | ||||
|           cp --recursive docker/ dist/paperless-ngx/docker | ||||
|  | ||||
|           echo "Copying startup scripts" | ||||
|           cp --verbose scripts/*.service scripts/*.sh scripts/*.socket dist/paperless-ngx/scripts/ | ||||
|  | ||||
|           echo "Copying source files" | ||||
|           cp --recursive src/ dist/paperless-ngx/src | ||||
|           echo "Copying documentation" | ||||
|           cp --recursive docs/_build/html/ dist/paperless-ngx/docs | ||||
|  | ||||
|           mv --verbose static dist/paperless-ngx | ||||
|       - name: Make release package | ||||
|         run: | | ||||
|           echo "Creating release archive" | ||||
|           cd dist | ||||
|           sudo chown -R 1000:1000 paperless-ngx/ | ||||
|           tar -cJf paperless-ngx.tar.xz paperless-ngx/ | ||||
|       - name: Upload release artifact | ||||
|         uses: actions/upload-artifact@v4 | ||||
|         with: | ||||
|           name: release | ||||
|           path: dist/paperless-ngx.tar.xz | ||||
|           retention-days: 7 | ||||
|   publish-release: | ||||
|     name: "Publish Release" | ||||
|     runs-on: ubuntu-24.04 | ||||
|     outputs: | ||||
|       prerelease: ${{ steps.get_version.outputs.prerelease }} | ||||
|       changelog: ${{ steps.create-release.outputs.body }} | ||||
|       version: ${{ steps.get_version.outputs.version }} | ||||
|     needs: | ||||
|       - build-release | ||||
|     if: github.ref_type == 'tag' && (startsWith(github.ref_name, 'v') || contains(github.ref_name, '-beta.rc')) | ||||
|     steps: | ||||
|       - name: Download release artifact | ||||
|         uses: actions/download-artifact@v5 | ||||
|         with: | ||||
|           name: release | ||||
|           path: ./ | ||||
|       - name: Get version | ||||
|         id: get_version | ||||
|         run: | | ||||
|           echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT | ||||
|           if [[ ${{ contains(github.ref_name, '-beta.rc') }} == 'true' ]]; then | ||||
|             echo "prerelease=true" >> $GITHUB_OUTPUT | ||||
|           else | ||||
|             echo "prerelease=false" >> $GITHUB_OUTPUT | ||||
|           fi | ||||
|       - name: Create Release and Changelog | ||||
|         id: create-release | ||||
|         uses: release-drafter/release-drafter@v6 | ||||
|         with: | ||||
|           name: Paperless-ngx ${{ steps.get_version.outputs.version }} | ||||
|           tag: ${{ steps.get_version.outputs.version }} | ||||
|           version: ${{ steps.get_version.outputs.version }} | ||||
|           prerelease: ${{ steps.get_version.outputs.prerelease }} | ||||
|           publish: true # ensures release is not marked as draft | ||||
|         env: | ||||
|           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||||
|       - name: Upload release archive | ||||
|         id: upload-release-asset | ||||
|         uses: shogo82148/actions-upload-release-asset@v1 | ||||
|         with: | ||||
|           github_token: ${{ secrets.GITHUB_TOKEN }} | ||||
|           upload_url: ${{ steps.create-release.outputs.upload_url }} | ||||
|           asset_path: ./paperless-ngx.tar.xz | ||||
|           asset_name: paperless-ngx-${{ steps.get_version.outputs.version }}.tar.xz | ||||
|           asset_content_type: application/x-xz | ||||
|   append-changelog: | ||||
|     name: "Append Changelog" | ||||
|     runs-on: ubuntu-24.04 | ||||
|     needs: | ||||
|       - publish-release | ||||
|     if: needs.publish-release.outputs.prerelease == 'false' | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: actions/checkout@v5 | ||||
|         with: | ||||
|           ref: main | ||||
|       - name: Set up Python | ||||
|         id: setup-python | ||||
|         uses: actions/setup-python@v5 | ||||
|         with: | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Install uv | ||||
|         uses: astral-sh/setup-uv@v6 | ||||
|         with: | ||||
|           version: ${{ env.DEFAULT_UV_VERSION }} | ||||
|           enable-cache: true | ||||
|           python-version: ${{ env.DEFAULT_PYTHON_VERSION }} | ||||
|       - name: Append Changelog to docs | ||||
|         id: append-Changelog | ||||
|         working-directory: docs | ||||
|         run: | | ||||
|           git branch ${{ needs.publish-release.outputs.version }}-changelog | ||||
|           git checkout ${{ needs.publish-release.outputs.version }}-changelog | ||||
|           echo -e "# Changelog\n\n${{ needs.publish-release.outputs.changelog }}\n" > changelog-new.md | ||||
|           echo "Manually linking usernames" | ||||
|           sed -i -r 's|@([a-zA-Z0-9_]+) \(\[#|[@\1](https://github.com/\1) ([#|g' changelog-new.md | ||||
|           echo "Removing unneeded comment tags" | ||||
|           sed -i -r 's|@<!---->|@|g' changelog-new.md | ||||
|           CURRENT_CHANGELOG=`tail --lines +2 changelog.md` | ||||
|           echo -e "$CURRENT_CHANGELOG" >> changelog-new.md | ||||
|           mv changelog-new.md changelog.md | ||||
|           uv run \ | ||||
|             --python ${{ steps.setup-python.outputs.python-version }} \ | ||||
|             --dev \ | ||||
|             pre-commit run --files changelog.md || true | ||||
|           git config --global user.name "github-actions" | ||||
|           git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" | ||||
|           git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA" | ||||
|           git push origin ${{ needs.publish-release.outputs.version }}-changelog | ||||
|       - name: Create Pull Request | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const { repo, owner } = context.repo; | ||||
|             const result = await github.rest.pulls.create({ | ||||
|               title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog', | ||||
|               owner, | ||||
|               repo, | ||||
|               head: '${{ needs.publish-release.outputs.version }}-changelog', | ||||
|               base: 'main', | ||||
|               body: 'This PR is auto-generated by CI.' | ||||
|             }); | ||||
|             github.rest.issues.addLabels({ | ||||
|               owner, | ||||
|               repo, | ||||
|               issue_number: result.data.number, | ||||
|               labels: ['documentation', 'skip-changelog'] | ||||
|             }); | ||||
|   | ||||
							
								
								
									
										220
									
								
								.github/workflows/codecov-comment.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										220
									
								
								.github/workflows/codecov-comment.yml
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,220 @@ | ||||
| name: Codecov PR Comment | ||||
| on: | ||||
|   workflow_run: | ||||
|     workflows: | ||||
|       - ci | ||||
|     types: | ||||
|       - completed | ||||
| permissions: | ||||
|   contents: read | ||||
|   pull-requests: write | ||||
| jobs: | ||||
|   comment: | ||||
|     if: >- | ||||
|       github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' | ||||
|     runs-on: ubuntu-24.04 | ||||
|     steps: | ||||
|       - name: Gather pull request context | ||||
|         id: pr | ||||
|         uses: actions/github-script@v7 | ||||
|         with: | ||||
|           script: | | ||||
|             const run = context.payload.workflow_run; | ||||
|             if (!run.pull_requests || run.pull_requests.length === 0) { | ||||
|               core.info('No associated pull request. Skipping.'); | ||||
|               return { shouldRun: false }; | ||||
|             } | ||||
|  | ||||
|             const pr = run.pull_requests[0]; | ||||
|             return { | ||||
|               shouldRun: true, | ||||
|               prNumber: pr.number, | ||||
|               headSha: run.head_sha, | ||||
|             }; | ||||
|       - name: Fetch Codecov coverage | ||||
|         id: coverage | ||||
|         if: steps.pr.outputs.shouldRun == 'true' | ||||
|         uses: actions/github-script@v7 | ||||
|         env: | ||||
|           CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} | ||||
|           COMMIT_SHA: ${{ steps.pr.outputs.headSha }} | ||||
|         with: | ||||
|           script: | | ||||
|             const token = process.env.CODECOV_TOKEN; | ||||
|             if (!token) { | ||||
|               core.warning('CODECOV_TOKEN secret is not available; skipping comment.'); | ||||
|               core.setOutput('shouldComment', 'false'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const commitSha = process.env.COMMIT_SHA; | ||||
|             const owner = context.repo.owner; | ||||
|             const repo = context.repo.repo; | ||||
|             const url = `https://codecov.io/api/v2/github/${owner}/repos/${repo}/commits/${commitSha}/report`; | ||||
|             const maxAttempts = 10; | ||||
|             const waitMs = 15000; | ||||
|             const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); | ||||
|  | ||||
|             let data; | ||||
|             for (let attempt = 1; attempt <= maxAttempts; attempt++) { | ||||
|               core.info(`Fetching Codecov report (attempt ${attempt}/${maxAttempts})`); | ||||
|               const response = await fetch(url, { | ||||
|                 headers: { | ||||
|                   Authorization: `Bearer ${token}`, | ||||
|                   'Content-Type': 'application/json', | ||||
|                   Accept: 'application/json', | ||||
|                 }, | ||||
|               }); | ||||
|  | ||||
|               if (response.status === 404) { | ||||
|                 core.info('Report not ready yet (404). Waiting before retrying.'); | ||||
|                 await sleep(waitMs); | ||||
|                 continue; | ||||
|               } | ||||
|  | ||||
|               if (!response.ok) { | ||||
|                 const text = await response.text(); | ||||
|                 throw new Error(`Codecov API returned ${response.status}: ${text}`); | ||||
|               } | ||||
|  | ||||
|               data = await response.json(); | ||||
|               if (data && Object.keys(data).length > 0) { | ||||
|                 break; | ||||
|               } | ||||
|  | ||||
|               core.info('Report payload empty. Waiting before retrying.'); | ||||
|               await sleep(waitMs); | ||||
|             } | ||||
|  | ||||
|             if (!data) { | ||||
|               core.warning('Unable to retrieve Codecov report after multiple attempts.'); | ||||
|               core.setOutput('shouldComment', 'false'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const totals = data.report?.totals ?? data.commit?.totals ?? data.totals; | ||||
|             if (!totals) { | ||||
|               core.warning('Codecov response does not contain coverage totals.'); | ||||
|               core.setOutput('shouldComment', 'false'); | ||||
|               return; | ||||
|             } | ||||
|  | ||||
|             const compareTotals = data.report?.compare?.totals ?? data.compare?.totals; | ||||
|             const flagsRaw = data.report?.totals_by_flag ?? data.report?.components ?? []; | ||||
|  | ||||
|             const toNumber = (value) => { | ||||
|               if (value === null || value === undefined || value === '') { | ||||
|                 return undefined; | ||||
|               } | ||||
|               const num = Number(value); | ||||
|               return Number.isFinite(num) ? num : undefined; | ||||
|             }; | ||||
|  | ||||
|             const coverage = toNumber(totals.coverage); | ||||
|             const baseCoverage = toNumber(compareTotals?.base_coverage ?? compareTotals?.base); | ||||
|             const delta = toNumber( | ||||
|               compareTotals?.coverage_change ?? | ||||
|               compareTotals?.coverage_diff ?? | ||||
|               totals.delta ?? | ||||
|               totals.diff ?? | ||||
|               totals.change, | ||||
|             ); | ||||
|  | ||||
|             const formatPercent = (value) => { | ||||
|               if (value === undefined) return '—'; | ||||
|               return `${value.toFixed(2)}%`; | ||||
|             }; | ||||
|  | ||||
|             const formatDelta = (value) => { | ||||
|               if (value === undefined) return '—'; | ||||
|               const sign = value >= 0 ? '+' : ''; | ||||
|               return `${sign}${value.toFixed(2)}%`; | ||||
|             }; | ||||
|  | ||||
|             const shortSha = commitSha.slice(0, 7); | ||||
|             const lines = [ | ||||
|               '<!-- codecov-coverage-comment -->', | ||||
|               '**Codecov Coverage**', | ||||
|               '', | ||||
|               `- Head \`${shortSha}\`: ${formatPercent(coverage)}`, | ||||
|             ]; | ||||
|  | ||||
|             if (baseCoverage !== undefined) { | ||||
|               lines.push(`- Base: ${formatPercent(baseCoverage)}`); | ||||
|             } | ||||
|  | ||||
|             if (delta !== undefined) { | ||||
|               lines.push(`- Change: ${formatDelta(delta)}`); | ||||
|             } | ||||
|  | ||||
|             const flagEntries = Array.isArray(flagsRaw) | ||||
|               ? flagsRaw | ||||
|               : Object.entries(flagsRaw).map(([name, totals]) => ({ name, totals })); | ||||
|  | ||||
|             const flagRows = []; | ||||
|             for (const entry of flagEntries) { | ||||
|               const label = entry.flag ?? entry.name ?? entry.component ?? entry.id; | ||||
|               const entryTotals = entry.totals ?? entry; | ||||
|               const entryCoverage = toNumber(entryTotals?.coverage); | ||||
|               const entryDelta = toNumber( | ||||
|                 entryTotals?.coverage_change ?? | ||||
|                 entryTotals?.coverage_diff ?? | ||||
|                 entryTotals?.delta ?? | ||||
|                 entryTotals?.diff, | ||||
|               ); | ||||
|               if (!label || entryCoverage === undefined) { | ||||
|                 continue; | ||||
|               } | ||||
|               flagRows.push(`| ${label} | ${formatPercent(entryCoverage)} | ${formatDelta(entryDelta)} |`); | ||||
|             } | ||||
|  | ||||
|             if (flagRows.length) { | ||||
|               lines.push(''); | ||||
|               lines.push('| Flag | Coverage | Change |'); | ||||
|               lines.push('| --- | --- | --- |'); | ||||
|               lines.push(...flagRows); | ||||
|             } | ||||
|  | ||||
|             const commentBody = lines.join('\n'); | ||||
|             const shouldComment = coverage !== undefined; | ||||
|             core.setOutput('shouldComment', shouldComment ? 'true' : 'false'); | ||||
|             if (shouldComment) { | ||||
|               core.setOutput('commentBody', commentBody); | ||||
|             } | ||||
|       - name: Upsert coverage comment | ||||
|         if: steps.pr.outputs.shouldRun == 'true' && steps.coverage.outputs.shouldComment == 'true' | ||||
|         uses: actions/github-script@v7 | ||||
|         env: | ||||
|           PR_NUMBER: ${{ steps.pr.outputs.prNumber }} | ||||
|           COMMENT_BODY: ${{ steps.coverage.outputs.commentBody }} | ||||
|         with: | ||||
|           script: | | ||||
|             const prNumber = Number(process.env.PR_NUMBER); | ||||
|             const body = process.env.COMMENT_BODY; | ||||
|             const marker = '<!-- codecov-coverage-comment -->'; | ||||
|  | ||||
|             const { data: comments } = await github.rest.issues.listComments({ | ||||
|               owner: context.repo.owner, | ||||
|               repo: context.repo.repo, | ||||
|               issue_number: prNumber, | ||||
|               per_page: 100, | ||||
|             }); | ||||
|  | ||||
|             const existing = comments.find((comment) => comment.body?.includes(marker)); | ||||
|             if (existing) { | ||||
|               core.info(`Updating existing coverage comment (id: ${existing.id}).`); | ||||
|               await github.rest.issues.updateComment({ | ||||
|                 owner: context.repo.owner, | ||||
|                 repo: context.repo.repo, | ||||
|                 comment_id: existing.id, | ||||
|                 body, | ||||
|               }); | ||||
|             } else { | ||||
|               core.info('Creating new coverage comment.'); | ||||
|               await github.rest.issues.createComment({ | ||||
|                 owner: context.repo.owner, | ||||
|                 repo: context.repo.repo, | ||||
|                 issue_number: prNumber, | ||||
|                 body, | ||||
|               }); | ||||
|             } | ||||
							
								
								
									
										1
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										1
									
								
								.github/workflows/repo-maintenance.yml
									
									
									
									
										vendored
									
									
								
							| @@ -241,6 +241,7 @@ jobs: | ||||
|                 ) { | ||||
|                   nodes { | ||||
|                     id, | ||||
|                     createdAt, | ||||
|                     number, | ||||
|                     updatedAt, | ||||
|                     upvoteCount, | ||||
|   | ||||
| @@ -2,9 +2,11 @@ | ||||
|  | ||||
| If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. | ||||
|  | ||||
| ⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged. | ||||
|  | ||||
| If you want to implement something big: | ||||
|  | ||||
| - Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together. | ||||
| - As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together. | ||||
| - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. | ||||
| - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. | ||||
| - Please see the [paperless-ngx merge process](#merging-prs) below. | ||||
| @@ -133,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and | ||||
| - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. | ||||
| - Discussions with a marked answer will be automatically closed. | ||||
| - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. | ||||
| - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years. | ||||
| - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years. | ||||
|  | ||||
| In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. | ||||
| Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. | ||||
|   | ||||
| @@ -1759,6 +1759,11 @@ started by the container. | ||||
|  | ||||
| : Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg` | ||||
|  | ||||
| !!! note | ||||
|  | ||||
|     The logo file will be viewable by anyone with access to the Paperless instance login page, | ||||
|     so consider your choice of logo carefully and removing exif data from images before uploading. | ||||
|  | ||||
| #### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK} | ||||
|  | ||||
| !!! note | ||||
|   | ||||
| @@ -261,6 +261,10 @@ different means. These are as follows: | ||||
| Paperless is set up to check your mails every 10 minutes. This can be | ||||
| configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON) | ||||
|  | ||||
| #### Processed Mail | ||||
|  | ||||
| Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs. | ||||
|  | ||||
| #### OAuth Email Setup | ||||
|  | ||||
| Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly. | ||||
|   | ||||
| @@ -755,11 +755,15 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">122</context> | ||||
|           <context context-type="linenumber">123</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">186</context> | ||||
|           <context context-type="linenumber">192</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">16</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> | ||||
| @@ -972,6 +976,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context> | ||||
|           <context context-type="linenumber">4</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">3</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6226301160429720843" datatype="html"> | ||||
|         <source> Update checking works by pinging the public GitHub API for the latest release to determine whether a new version is available. Actual updating of the app must still be performed manually. </source> | ||||
| @@ -1217,11 +1225,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">148</context> | ||||
|           <context context-type="linenumber">154</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">160</context> | ||||
|           <context context-type="linenumber">166</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> | ||||
| @@ -1812,7 +1820,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">115</context> | ||||
|           <context context-type="linenumber">116</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> | ||||
| @@ -2004,6 +2012,14 @@ | ||||
|           <context context-type="sourcefile">src/app/components/admin/trash/trash.component.html</context> | ||||
|           <context context-type="linenumber">14</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">87</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">89</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8597030111956627342" datatype="html"> | ||||
|         <source>Empty trash</source> | ||||
| @@ -2113,11 +2129,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">149</context> | ||||
|           <context context-type="linenumber">155</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">163</context> | ||||
|           <context context-type="linenumber">169</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> | ||||
| @@ -2241,11 +2257,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">191</context> | ||||
|           <context context-type="linenumber">192</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">292</context> | ||||
|           <context context-type="linenumber">293</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> | ||||
| @@ -2432,11 +2448,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">147</context> | ||||
|           <context context-type="linenumber">153</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">157</context> | ||||
|           <context context-type="linenumber">163</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context> | ||||
| @@ -2568,11 +2584,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">193</context> | ||||
|           <context context-type="linenumber">194</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">294</context> | ||||
|           <context context-type="linenumber">295</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> | ||||
| @@ -3129,6 +3145,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/common/clearable-badge/clearable-badge.component.html</context> | ||||
|           <context context-type="linenumber">2</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">85</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7515883357904500238" datatype="html"> | ||||
|         <source>Are you sure?</source> | ||||
| @@ -3896,7 +3916,7 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">136</context> | ||||
|           <context context-type="linenumber">137</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> | ||||
| @@ -4106,6 +4126,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/common/toast/toast.component.html</context> | ||||
|           <context context-type="linenumber">30</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">36</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6886003843406464884" datatype="html"> | ||||
|         <source>Only process attachments</source> | ||||
| @@ -5109,6 +5133,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context> | ||||
|           <context context-type="linenumber">11</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">32</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8066608938393600549" datatype="html"> | ||||
|         <source>Message</source> | ||||
| @@ -5478,6 +5506,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context> | ||||
|           <context context-type="linenumber">9</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">7</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5034217198277582100" datatype="html"> | ||||
|         <source>Select all pages</source> | ||||
| @@ -5745,11 +5777,11 @@ | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">150</context> | ||||
|           <context context-type="linenumber">156</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">168</context> | ||||
|           <context context-type="linenumber">174</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> | ||||
| @@ -6127,6 +6159,10 @@ | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">114</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">35</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> | ||||
|           <context context-type="linenumber">19</context> | ||||
| @@ -8517,185 +8553,227 @@ | ||||
|         <source>Disabled</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">136</context> | ||||
|           <context context-type="linenumber">137</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context> | ||||
|           <context context-type="linenumber">41</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8996068874121140407" datatype="html"> | ||||
|         <source>View Processed Mail</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">143</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6751234988479444294" datatype="html"> | ||||
|         <source>No mail rules defined.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context> | ||||
|           <context context-type="linenumber">177</context> | ||||
|           <context context-type="linenumber">183</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3178554336792037159" datatype="html"> | ||||
|         <source>Error retrieving mail accounts</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">104</context> | ||||
|           <context context-type="linenumber">105</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5241231471117657636" datatype="html"> | ||||
|         <source>Error retrieving mail rules</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">126</context> | ||||
|           <context context-type="linenumber">127</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="763945516325093575" datatype="html"> | ||||
|         <source>OAuth2 authentication success</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">134</context> | ||||
|           <context context-type="linenumber">135</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="9022978370268070156" datatype="html"> | ||||
|         <source>OAuth2 authentication failed, see logs for details</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">145</context> | ||||
|           <context context-type="linenumber">146</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6327501535846658797" datatype="html"> | ||||
|         <source>Saved account "<x id="PH" equiv-text="newMailAccount.name"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">169</context> | ||||
|           <context context-type="linenumber">170</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8067594003836508139" datatype="html"> | ||||
|         <source>Error saving account.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">181</context> | ||||
|           <context context-type="linenumber">182</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5641934153807844674" datatype="html"> | ||||
|         <source>Confirm delete mail account</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">189</context> | ||||
|           <context context-type="linenumber">190</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7176985344323395435" datatype="html"> | ||||
|         <source>This operation will permanently delete this mail account.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">190</context> | ||||
|           <context context-type="linenumber">191</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5876433590301754883" datatype="html"> | ||||
|         <source>Deleted mail account "<x id="PH" equiv-text="account.name"/>"</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">200</context> | ||||
|           <context context-type="linenumber">201</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="5981429299543258715" datatype="html"> | ||||
|         <source>Error deleting mail account "<x id="PH" equiv-text="account.name"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">211</context> | ||||
|           <context context-type="linenumber">212</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="6424800796582120505" datatype="html"> | ||||
|         <source>Processing mail account "<x id="PH" equiv-text="account.name"/>"</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">223</context> | ||||
|           <context context-type="linenumber">224</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3138185874003827652" datatype="html"> | ||||
|         <source>Error processing mail account "<x id="PH" equiv-text="account.name"/>"</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">228</context> | ||||
|           <context context-type="linenumber">229</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="123368655395433699" datatype="html"> | ||||
|         <source>Saved rule "<x id="PH" equiv-text="newMailRule.name"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">246</context> | ||||
|           <context context-type="linenumber">247</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8951124554918814321" datatype="html"> | ||||
|         <source>Error saving rule.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">257</context> | ||||
|           <context context-type="linenumber">258</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3574401690710711341" datatype="html"> | ||||
|         <source>Rule "<x id="PH" equiv-text="rule.name"/>" enabled.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">273</context> | ||||
|           <context context-type="linenumber">274</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7171685227222299542" datatype="html"> | ||||
|         <source>Rule "<x id="PH" equiv-text="rule.name"/>" disabled.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">274</context> | ||||
|           <context context-type="linenumber">275</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="7238791203524413596" datatype="html"> | ||||
|         <source>Error toggling rule "<x id="PH" equiv-text="rule.name"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">279</context> | ||||
|           <context context-type="linenumber">280</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3896080636020672118" datatype="html"> | ||||
|         <source>Confirm delete mail rule</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">290</context> | ||||
|           <context context-type="linenumber">291</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2250372580580310337" datatype="html"> | ||||
|         <source>This operation will permanently delete this mail rule.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">291</context> | ||||
|           <context context-type="linenumber">292</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4357654589451732716" datatype="html"> | ||||
|         <source>Deleted mail rule "<x id="PH" equiv-text="rule.name"/>"</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">301</context> | ||||
|           <context context-type="linenumber">302</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1696130068388341598" datatype="html"> | ||||
|         <source>Error deleting mail rule "<x id="PH" equiv-text="rule.name"/>".</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">312</context> | ||||
|           <context context-type="linenumber">313</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3061362835271417984" datatype="html"> | ||||
|         <source>Permissions updated</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">336</context> | ||||
|           <context context-type="linenumber">337</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4639647950943944112" datatype="html"> | ||||
|         <source>Error updating permissions</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context> | ||||
|           <context context-type="linenumber">341</context> | ||||
|           <context context-type="linenumber">342</context> | ||||
|         </context-group> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context> | ||||
|           <context context-type="linenumber">339</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="3501895737484542570" datatype="html"> | ||||
|         <source>Processed Mail for <x id="START_EMPHASISED_TEXT" ctype="x-em" equiv-text="<em>"/><x id="INTERPOLATION" equiv-text="{{ rule.name }}"/><x id="CLOSE_EMPHASISED_TEXT" ctype="x-em" equiv-text="</em>"/></source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">2</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="1991019495862291373" datatype="html"> | ||||
|         <source>No processed email messages found.</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">20</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="8691920320483720007" datatype="html"> | ||||
|         <source>Received</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">33</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4749295647449765550" datatype="html"> | ||||
|         <source>Processed</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html</context> | ||||
|           <context context-type="linenumber">34</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="2175109571923803648" datatype="html"> | ||||
|         <source>Processed mail(s) deleted</source> | ||||
|         <context-group purpose="location"> | ||||
|           <context context-type="sourcefile">src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts</context> | ||||
|           <context context-type="linenumber">72</context> | ||||
|         </context-group> | ||||
|       </trans-unit> | ||||
|       <trans-unit id="4010735610815226758" datatype="html"> | ||||
|         <source>Filter by:</source> | ||||
|         <context-group purpose="location"> | ||||
|   | ||||
| @@ -109,10 +109,11 @@ | ||||
|     <li class="list-group-item"> | ||||
|       <div class="row"> | ||||
|         <div class="col" i18n>Name</div> | ||||
|         <div class="col d-none d-sm-block" i18n>Sort Order</div> | ||||
|         <div class="col" i18n>Account</div> | ||||
|         <div class="col d-none d-sm-block" i18n>Status</div> | ||||
|         <div class="col" i18n>Actions</div> | ||||
|         <div class="col-1 d-none d-sm-block" i18n>Sort Order</div> | ||||
|         <div class="col-2" i18n>Account</div> | ||||
|         <div class="col-2 d-none d-sm-block" i18n>Status</div> | ||||
|         <div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div> | ||||
|         <div class="col-3" i18n>Actions</div> | ||||
|       </div> | ||||
|     </li> | ||||
|  | ||||
| @@ -127,9 +128,9 @@ | ||||
|       <li class="list-group-item"> | ||||
|         <div class="row fade" [class.show]="showRules"> | ||||
|           <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div> | ||||
|           <div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div> | ||||
|           <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|           <div class="col d-flex align-items-center d-none d-sm-flex"> | ||||
|           <div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div> | ||||
|           <div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> | ||||
|           <div class="col-2 d-flex align-items-center d-none d-sm-flex"> | ||||
|             <div class="form-check form-switch mb-0"> | ||||
|               <input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }"> | ||||
|               <label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'"> | ||||
| @@ -137,7 +138,12 @@ | ||||
|               </label> | ||||
|             </div> | ||||
|           </div> | ||||
|           <div class="col"> | ||||
|           <div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }"> | ||||
|             <button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)"> | ||||
|               <i-bs width="1em" height="1em" name="clock-history"></i-bs> <ng-container i18n>View Processed Mail</ng-container> | ||||
|             </button> | ||||
|           </div> | ||||
|           <div class="col-3"> | ||||
|             <div class="btn-group d-block d-sm-none"> | ||||
|               <div ngbDropdown container="body" class="d-inline-block"> | ||||
|                 <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> | ||||
|   | ||||
| @@ -409,4 +409,13 @@ describe('MailComponent', () => { | ||||
|     jest.advanceTimersByTime(200) | ||||
|     expect(editSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should open processed mails dialog', () => { | ||||
|     completeSetup() | ||||
|     let modal: NgbModalRef | ||||
|     modalService.activeInstances.subscribe((refs) => (modal = refs[0])) | ||||
|     component.viewProcessedMail(mailRules[0] as MailRule) | ||||
|     const dialog = modal.componentInstance as any | ||||
|     expect(dialog.rule).toEqual(mailRules[0]) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -27,6 +27,7 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule- | ||||
| import { PageHeaderComponent } from '../../common/page-header/page-header.component' | ||||
| import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component' | ||||
| import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' | ||||
| import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-mail', | ||||
| @@ -347,6 +348,14 @@ export class MailComponent | ||||
|     ) | ||||
|   } | ||||
|  | ||||
|   viewProcessedMail(rule: MailRule) { | ||||
|     const modal = this.modalService.open(ProcessedMailDialogComponent, { | ||||
|       backdrop: 'static', | ||||
|       size: 'xl', | ||||
|     }) | ||||
|     modal.componentInstance.rule = rule | ||||
|   } | ||||
|  | ||||
|   userCanEdit(obj: ObjectWithPermissions): boolean { | ||||
|     return this.permissionsService.currentUserHasObjectPermissions( | ||||
|       PermissionAction.Change, | ||||
|   | ||||
| @@ -0,0 +1,107 @@ | ||||
| <div class="modal-header"> | ||||
|   <h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6> | ||||
|   <button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true"> | ||||
|     <i-bs name="question-circle"></i-bs> | ||||
|   </button> | ||||
|   <ng-template #infoPopover> | ||||
|     <a href="https://docs.paperless-ngx.com/usage#processed-mail" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a> | ||||
|     <i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs> | ||||
|   </ng-template> | ||||
|   <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> | ||||
| </div> | ||||
| <div class="modal-body"> | ||||
|   @if (loading) { | ||||
|     <div class="text-center my-5"> | ||||
|       <div class="spinner-border" role="status"> | ||||
|         <span class="visually-hidden" i18n>Loading...</span> | ||||
|       </div> | ||||
|     </div> | ||||
|   } @else if (processedMails.length === 0) { | ||||
|     <span i18n>No processed email messages found.</span> | ||||
|   } @else { | ||||
|     <div class="table-responsive"> | ||||
|       <table class="table table-hover table-sm align-middle"> | ||||
|         <thead> | ||||
|           <tr> | ||||
|             <th scope="col" style="width: 40px;"> | ||||
|               <div class="form-check m-0 ms-2 me-n2"> | ||||
|                 <input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();"> | ||||
|                 <label class="form-check-label" for="all-objects"></label> | ||||
|               </div> | ||||
|             </th> | ||||
|             <th scope="col" i18n>Subject</th> | ||||
|             <th scope="col" i18n>Received</th> | ||||
|             <th scope="col" i18n>Processed</th> | ||||
|             <th scope="col" i18n>Status</th> | ||||
|             <th scope="col" i18n>Error</th> | ||||
|           </tr> | ||||
|         </thead> | ||||
|         <tbody> | ||||
|           @for (mail of processedMails; track mail.id) { | ||||
|             <ng-template #statusTooltip> | ||||
|               <div class="small text-light font-monospace"> | ||||
|                   {{mail.status}} | ||||
|               </div> | ||||
|             </ng-template> | ||||
|             <tr> | ||||
|               <td> | ||||
|                 <div class="form-check m-0 ms-2 me-n2"> | ||||
|                   <input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();"> | ||||
|                   <label class="form-check-label" [for]="mail.id"></label> | ||||
|                 </div> | ||||
|               </td> | ||||
|               <td>{{ mail.subject }}</td> | ||||
|               <td>{{ mail.received | customDate:'longDate' }}</td> | ||||
|               <td>{{ mail.processed | customDate:'longDate' }}</td> | ||||
|               <td> | ||||
|                 @switch (mail.status) { | ||||
|                   @case ('SUCCESS') { | ||||
|                     <i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="statusTooltip"></i-bs> | ||||
|                   } | ||||
|                   @case ('FAILED') { | ||||
|                     <i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="statusTooltip"></i-bs> | ||||
|                   } | ||||
|                   @default { | ||||
|                     <i-bs name="slash-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="statusTooltip"></i-bs> | ||||
|                   } | ||||
|                 } | ||||
|               </td> | ||||
|               <td> | ||||
|                 <ng-template #errorPopover> | ||||
|                   <pre class="small text-light"> | ||||
|                     {{ mail.error }} | ||||
|                   </pre> | ||||
|                 </ng-template> | ||||
|                 @if (mail.error) { | ||||
|                   <span class="text-danger" triggers="mouseenter:mouseleave" [ngbPopover]="errorPopover">{{ mail.error | slice:0:20 }}</span> | ||||
|                 } | ||||
|               </td> | ||||
|             </tr> | ||||
|           } | ||||
|         </tbody> | ||||
|       </table> | ||||
|     </div> | ||||
|     <div class="btn-toolbar"> | ||||
|       <button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button> | ||||
|       <pngx-confirm-button | ||||
|         label="Delete selected" | ||||
|         i18n-label | ||||
|         title="Delete selected" | ||||
|         i18n-title | ||||
|         buttonClasses="btn-outline-danger" | ||||
|         iconName="trash" | ||||
|         [disabled]="selectedMailIds.size === 0" | ||||
|         (confirm)="deleteSelected()"> | ||||
|       </pngx-confirm-button> | ||||
|       <div class="ms-auto"> | ||||
|         <ngb-pagination | ||||
|           [collectionSize]="processedMails.length" | ||||
|           [(page)]="page" | ||||
|           [pageSize]="50" | ||||
|           [maxSize]="5" | ||||
|           (pageChange)="loadProcessedMails()"> | ||||
|         </ngb-pagination> | ||||
|       </div> | ||||
|     </div> | ||||
|   } | ||||
| </div> | ||||
| @@ -0,0 +1,8 @@ | ||||
| ::ng-deep .popover { | ||||
|     max-width: 350px; | ||||
|  | ||||
|     pre { | ||||
|         white-space: pre-wrap; | ||||
|         word-break: break-word; | ||||
|     } | ||||
| } | ||||
| @@ -0,0 +1,150 @@ | ||||
| import { DatePipe } from '@angular/common' | ||||
| import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' | ||||
| import { | ||||
|   HttpTestingController, | ||||
|   provideHttpClientTesting, | ||||
| } from '@angular/common/http/testing' | ||||
| import { ComponentFixture, TestBed } from '@angular/core/testing' | ||||
| import { FormsModule } from '@angular/forms' | ||||
| import { By } from '@angular/platform-browser' | ||||
| import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { ProcessedMailDialogComponent } from './processed-mail-dialog.component' | ||||
|  | ||||
| describe('ProcessedMailDialogComponent', () => { | ||||
|   let component: ProcessedMailDialogComponent | ||||
|   let fixture: ComponentFixture<ProcessedMailDialogComponent> | ||||
|   let httpTestingController: HttpTestingController | ||||
|   let toastService: ToastService | ||||
|  | ||||
|   const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests | ||||
|   const mails = [ | ||||
|     { | ||||
|       id: 1, | ||||
|       rule: rule.id, | ||||
|       folder: 'INBOX', | ||||
|       uid: 111, | ||||
|       subject: 'A', | ||||
|       received: new Date().toISOString(), | ||||
|       processed: new Date().toISOString(), | ||||
|       status: 'SUCCESS', | ||||
|       error: null, | ||||
|     }, | ||||
|     { | ||||
|       id: 2, | ||||
|       rule: rule.id, | ||||
|       folder: 'INBOX', | ||||
|       uid: 222, | ||||
|       subject: 'B', | ||||
|       received: new Date().toISOString(), | ||||
|       processed: new Date().toISOString(), | ||||
|       status: 'FAILED', | ||||
|       error: 'Oops', | ||||
|     }, | ||||
|   ] | ||||
|  | ||||
|   beforeEach(async () => { | ||||
|     await TestBed.configureTestingModule({ | ||||
|       imports: [ | ||||
|         ProcessedMailDialogComponent, | ||||
|         FormsModule, | ||||
|         NgxBootstrapIconsModule.pick(allIcons), | ||||
|       ], | ||||
|       providers: [ | ||||
|         DatePipe, | ||||
|         NgbActiveModal, | ||||
|         provideHttpClient(withInterceptorsFromDi()), | ||||
|         provideHttpClientTesting(), | ||||
|       ], | ||||
|     }).compileComponents() | ||||
|  | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     toastService = TestBed.inject(ToastService) | ||||
|     fixture = TestBed.createComponent(ProcessedMailDialogComponent) | ||||
|     component = fixture.componentInstance | ||||
|     component.rule = rule | ||||
|   }) | ||||
|  | ||||
|   afterEach(() => { | ||||
|     httpTestingController.verify() | ||||
|   }) | ||||
|  | ||||
|   function expectListRequest(ruleId: number) { | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('GET') | ||||
|     return req | ||||
|   } | ||||
|  | ||||
|   it('should load processed mails on init', () => { | ||||
|     fixture.detectChanges() | ||||
|     const req = expectListRequest(rule.id) | ||||
|     req.flush({ count: 2, results: mails }) | ||||
|     expect(component.loading).toBeFalsy() | ||||
|     expect(component.processedMails).toEqual(mails) | ||||
|   }) | ||||
|  | ||||
|   it('should delete selected mails and reload', () => { | ||||
|     fixture.detectChanges() | ||||
|     // initial load | ||||
|     const initialReq = expectListRequest(rule.id) | ||||
|     initialReq.flush({ count: 0, results: [] }) | ||||
|  | ||||
|     // select a couple of mails and delete | ||||
|     component.selectedMailIds.add(5) | ||||
|     component.selectedMailIds.add(6) | ||||
|     const toastInfoSpy = jest.spyOn(toastService, 'showInfo') | ||||
|     component.deleteSelected() | ||||
|  | ||||
|     const delReq = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}processed_mail/bulk_delete/` | ||||
|     ) | ||||
|     expect(delReq.request.method).toEqual('POST') | ||||
|     expect(delReq.request.body).toEqual({ mail_ids: [5, 6] }) | ||||
|     delReq.flush({}) | ||||
|  | ||||
|     // reload after delete | ||||
|     const reloadReq = expectListRequest(rule.id) | ||||
|     reloadReq.flush({ count: 0, results: [] }) | ||||
|     expect(toastInfoSpy).toHaveBeenCalled() | ||||
|   }) | ||||
|  | ||||
|   it('should toggle all, toggle selected, and clear selection', () => { | ||||
|     fixture.detectChanges() | ||||
|     // initial load with two mails | ||||
|     const req = expectListRequest(rule.id) | ||||
|     req.flush({ count: 2, results: mails }) | ||||
|     fixture.detectChanges() | ||||
|  | ||||
|     // toggle all via header checkbox | ||||
|     const inputs = fixture.debugElement.queryAll( | ||||
|       By.css('input.form-check-input') | ||||
|     ) | ||||
|     const header = inputs[0].nativeElement as HTMLInputElement | ||||
|     header.dispatchEvent(new Event('click')) | ||||
|     header.checked = true | ||||
|     header.dispatchEvent(new Event('click')) | ||||
|     expect(component.selectedMailIds.size).toEqual(mails.length) | ||||
|  | ||||
|     // toggle a single mail | ||||
|     component.toggleSelected(mails[0] as any) | ||||
|     expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy() | ||||
|     component.toggleSelected(mails[0] as any) | ||||
|     expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy() | ||||
|  | ||||
|     // clear selection | ||||
|     component.clearSelection() | ||||
|     expect(component.selectedMailIds.size).toEqual(0) | ||||
|     expect(component.toggleAllEnabled).toBeFalsy() | ||||
|   }) | ||||
|  | ||||
|   it('should close the dialog', () => { | ||||
|     const activeModal = TestBed.inject(NgbActiveModal) | ||||
|     const closeSpy = jest.spyOn(activeModal, 'close') | ||||
|     component.close() | ||||
|     expect(closeSpy).toHaveBeenCalled() | ||||
|   }) | ||||
| }) | ||||
| @@ -0,0 +1,96 @@ | ||||
| import { SlicePipe } from '@angular/common' | ||||
| import { Component, inject, Input, OnInit } from '@angular/core' | ||||
| import { FormsModule, ReactiveFormsModule } from '@angular/forms' | ||||
| import { | ||||
|   NgbActiveModal, | ||||
|   NgbPagination, | ||||
|   NgbPopoverModule, | ||||
|   NgbTooltipModule, | ||||
| } from '@ng-bootstrap/ng-bootstrap' | ||||
| import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' | ||||
| import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component' | ||||
| import { MailRule } from 'src/app/data/mail-rule' | ||||
| import { ProcessedMail } from 'src/app/data/processed-mail' | ||||
| import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' | ||||
| import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service' | ||||
| import { ToastService } from 'src/app/services/toast.service' | ||||
|  | ||||
| @Component({ | ||||
|   selector: 'pngx-processed-mail-dialog', | ||||
|   imports: [ | ||||
|     ConfirmButtonComponent, | ||||
|     CustomDatePipe, | ||||
|     NgbPagination, | ||||
|     NgbPopoverModule, | ||||
|     NgbTooltipModule, | ||||
|     NgxBootstrapIconsModule, | ||||
|     FormsModule, | ||||
|     ReactiveFormsModule, | ||||
|     SlicePipe, | ||||
|   ], | ||||
|   templateUrl: './processed-mail-dialog.component.html', | ||||
|   styleUrl: './processed-mail-dialog.component.scss', | ||||
| }) | ||||
| export class ProcessedMailDialogComponent implements OnInit { | ||||
|   private readonly activeModal = inject(NgbActiveModal) | ||||
|   private readonly processedMailService = inject(ProcessedMailService) | ||||
|   private readonly toastService = inject(ToastService) | ||||
|  | ||||
|   public processedMails: ProcessedMail[] = [] | ||||
|  | ||||
|   public loading: boolean = true | ||||
|   public toggleAllEnabled: boolean = false | ||||
|   public readonly selectedMailIds: Set<number> = new Set<number>() | ||||
|  | ||||
|   public page: number = 1 | ||||
|  | ||||
|   @Input() rule: MailRule | ||||
|  | ||||
|   ngOnInit(): void { | ||||
|     this.loadProcessedMails() | ||||
|   } | ||||
|  | ||||
|   public close() { | ||||
|     this.activeModal.close() | ||||
|   } | ||||
|  | ||||
|   private loadProcessedMails(): void { | ||||
|     this.loading = true | ||||
|     this.clearSelection() | ||||
|     this.processedMailService | ||||
|       .list(this.page, 50, 'processed_at', true, { rule: this.rule.id }) | ||||
|       .subscribe((result) => { | ||||
|         this.processedMails = result.results | ||||
|         this.loading = false | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public deleteSelected(): void { | ||||
|     this.processedMailService | ||||
|       .bulk_delete(Array.from(this.selectedMailIds)) | ||||
|       .subscribe(() => { | ||||
|         this.toastService.showInfo($localize`Processed mail(s) deleted`) | ||||
|         this.loadProcessedMails() | ||||
|       }) | ||||
|   } | ||||
|  | ||||
|   public toggleAll(event: PointerEvent) { | ||||
|     if ((event.target as HTMLInputElement).checked) { | ||||
|       this.selectedMailIds.clear() | ||||
|       this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id)) | ||||
|     } else { | ||||
|       this.clearSelection() | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   public clearSelection() { | ||||
|     this.toggleAllEnabled = false | ||||
|     this.selectedMailIds.clear() | ||||
|   } | ||||
|  | ||||
|   public toggleSelected(mail: ProcessedMail) { | ||||
|     this.selectedMailIds.has(mail.id) | ||||
|       ? this.selectedMailIds.delete(mail.id) | ||||
|       : this.selectedMailIds.add(mail.id) | ||||
|   } | ||||
| } | ||||
| @@ -71,4 +71,20 @@ describe('TagListComponent', () => { | ||||
|       'Do you really want to delete the tag "Tag1"?' | ||||
|     ) | ||||
|   }) | ||||
|  | ||||
|   it('should filter out child tags if name filter is empty, otherwise show all', () => { | ||||
|     const tags = [ | ||||
|       { id: 1, name: 'Tag1', parent: null }, | ||||
|       { id: 2, name: 'Tag2', parent: 1 }, | ||||
|       { id: 3, name: 'Tag3', parent: null }, | ||||
|     ] | ||||
|     component['_nameFilter'] = null // Simulate empty name filter | ||||
|     const filtered = component.filterData(tags as any) | ||||
|     expect(filtered.length).toBe(2) | ||||
|     expect(filtered.find((t) => t.id === 2)).toBeUndefined() | ||||
|  | ||||
|     component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter | ||||
|     const filteredWithName = component.filterData(tags as any) | ||||
|     expect(filteredWithName.length).toBe(3) | ||||
|   }) | ||||
| }) | ||||
|   | ||||
| @@ -62,6 +62,8 @@ export class TagListComponent extends ManagementListComponent<Tag> { | ||||
|   } | ||||
|  | ||||
|   filterData(data: Tag[]) { | ||||
|     return data.filter((tag) => !tag.parent) | ||||
|     return this.nameFilter?.length | ||||
|       ? [...data] | ||||
|       : data.filter((tag) => !tag.parent) | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										12
									
								
								src-ui/src/app/data/processed-mail.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src-ui/src/app/data/processed-mail.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| import { ObjectWithId } from './object-with-id' | ||||
|  | ||||
| export interface ProcessedMail extends ObjectWithId { | ||||
|   rule: number // MailRule.id | ||||
|   folder: string | ||||
|   uid: number | ||||
|   subject: string | ||||
|   received: Date | ||||
|   processed: Date | ||||
|   status: string | ||||
|   error: string | ||||
| } | ||||
| @@ -28,6 +28,7 @@ export enum PermissionType { | ||||
|   ShareLink = '%s_sharelink', | ||||
|   CustomField = '%s_customfield', | ||||
|   Workflow = '%s_workflow', | ||||
|   ProcessedMail = '%s_processedmail', | ||||
| } | ||||
|  | ||||
| @Injectable({ | ||||
|   | ||||
							
								
								
									
										39
									
								
								src-ui/src/app/services/rest/processed-mail.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								src-ui/src/app/services/rest/processed-mail.service.spec.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,39 @@ | ||||
| import { HttpTestingController } from '@angular/common/http/testing' | ||||
| import { TestBed } from '@angular/core/testing' | ||||
| import { Subscription } from 'rxjs' | ||||
| import { environment } from 'src/environments/environment' | ||||
| import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec' | ||||
| import { ProcessedMailService } from './processed-mail.service' | ||||
|  | ||||
| let httpTestingController: HttpTestingController | ||||
| let service: ProcessedMailService | ||||
| let subscription: Subscription | ||||
| const endpoint = 'processed_mail' | ||||
|  | ||||
| // run common tests | ||||
| commonAbstractPaperlessServiceTests(endpoint, ProcessedMailService) | ||||
|  | ||||
| describe('Additional service tests for ProcessedMailService', () => { | ||||
|   beforeEach(() => { | ||||
|     // Dont need to setup again | ||||
|  | ||||
|     httpTestingController = TestBed.inject(HttpTestingController) | ||||
|     service = TestBed.inject(ProcessedMailService) | ||||
|   }) | ||||
|  | ||||
|   afterEach(() => { | ||||
|     subscription?.unsubscribe() | ||||
|     httpTestingController.verify() | ||||
|   }) | ||||
|  | ||||
|   it('should call appropriate api endpoint for bulk delete', () => { | ||||
|     const ids = [1, 2, 3] | ||||
|     subscription = service.bulk_delete(ids).subscribe() | ||||
|     const req = httpTestingController.expectOne( | ||||
|       `${environment.apiBaseUrl}${endpoint}/bulk_delete/` | ||||
|     ) | ||||
|     expect(req.request.method).toEqual('POST') | ||||
|     expect(req.request.body).toEqual({ mail_ids: ids }) | ||||
|     req.flush({}) | ||||
|   }) | ||||
| }) | ||||
							
								
								
									
										19
									
								
								src-ui/src/app/services/rest/processed-mail.service.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								src-ui/src/app/services/rest/processed-mail.service.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,19 @@ | ||||
| import { Injectable } from '@angular/core' | ||||
| import { ProcessedMail } from 'src/app/data/processed-mail' | ||||
| import { AbstractPaperlessService } from './abstract-paperless-service' | ||||
|  | ||||
| @Injectable({ | ||||
|   providedIn: 'root', | ||||
| }) | ||||
| export class ProcessedMailService extends AbstractPaperlessService<ProcessedMail> { | ||||
|   constructor() { | ||||
|     super() | ||||
|     this.resourceName = 'processed_mail' | ||||
|   } | ||||
|  | ||||
|   public bulk_delete(mailIds: number[]) { | ||||
|     return this.http.post(`${this.getResourceUrl()}bulk_delete/`, { | ||||
|       mail_ids: mailIds, | ||||
|     }) | ||||
|   } | ||||
| } | ||||
| @@ -51,6 +51,7 @@ import { | ||||
|   check, | ||||
|   check2All, | ||||
|   checkAll, | ||||
|   checkCircle, | ||||
|   checkCircleFill, | ||||
|   checkLg, | ||||
|   chevronDoubleLeft, | ||||
| @@ -60,6 +61,7 @@ import { | ||||
|   clipboardCheck, | ||||
|   clipboardCheckFill, | ||||
|   clipboardFill, | ||||
|   clockHistory, | ||||
|   dash, | ||||
|   dashCircle, | ||||
|   diagram3, | ||||
| @@ -263,6 +265,7 @@ const icons = { | ||||
|   check, | ||||
|   check2All, | ||||
|   checkAll, | ||||
|   checkCircle, | ||||
|   checkCircleFill, | ||||
|   checkLg, | ||||
|   chevronDoubleLeft, | ||||
| @@ -272,6 +275,7 @@ const icons = { | ||||
|   clipboardCheck, | ||||
|   clipboardCheckFill, | ||||
|   clipboardFill, | ||||
|   clockHistory, | ||||
|   dash, | ||||
|   dashCircle, | ||||
|   diagram3, | ||||
|   | ||||
| @@ -164,6 +164,9 @@ class BarcodePlugin(ConsumeTaskPlugin): | ||||
|                         mailrule_id=self.input_doc.mailrule_id, | ||||
|                         # Can't use same folder or the consume might grab it again | ||||
|                         original_file=(tmp_dir / new_document.name).resolve(), | ||||
|                         # Adding optional original_path for later uses in | ||||
|                         # workflow matching | ||||
|                         original_path=self.input_doc.original_file, | ||||
|                     ), | ||||
|                     # All the same metadata | ||||
|                     self.metadata, | ||||
|   | ||||
| @@ -156,6 +156,7 @@ class ConsumableDocument: | ||||
|  | ||||
|     source: DocumentSource | ||||
|     original_file: Path | ||||
|     original_path: Path | None = None | ||||
|     mailrule_id: int | None = None | ||||
|     mime_type: str = dataclasses.field(init=False, default=None) | ||||
|  | ||||
|   | ||||
| @@ -82,6 +82,13 @@ def _is_ignored(filepath: Path) -> bool: | ||||
|  | ||||
|  | ||||
| def _consume(filepath: Path) -> None: | ||||
|     # Check permissions early | ||||
|     try: | ||||
|         filepath.stat() | ||||
|     except (PermissionError, OSError): | ||||
|         logger.warning(f"Not consuming file {filepath}: Permission denied.") | ||||
|         return | ||||
|  | ||||
|     if filepath.is_dir() or _is_ignored(filepath): | ||||
|         return | ||||
|  | ||||
| @@ -323,7 +330,12 @@ class Command(BaseCommand): | ||||
|  | ||||
|                         # Also make sure the file exists still, some scanners might write a | ||||
|                         # temporary file first | ||||
|                         file_still_exists = filepath.exists() and filepath.is_file() | ||||
|                         try: | ||||
|                             file_still_exists = filepath.exists() and filepath.is_file() | ||||
|                         except (PermissionError, OSError):  # pragma: no cover | ||||
|                             # If we can't check, let it fail in the _consume function | ||||
|                             file_still_exists = True | ||||
|                             continue | ||||
|  | ||||
|                         if waited_long_enough and file_still_exists: | ||||
|                             _consume(filepath) | ||||
|   | ||||
| @@ -92,6 +92,9 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand): | ||||
|                 # doc to doc is obviously not useful | ||||
|                 if first_doc.pk == second_doc.pk: | ||||
|                     continue | ||||
|                 # Skip empty documents (e.g. password-protected) | ||||
|                 if first_doc.content.strip() == "" or second_doc.content.strip() == "": | ||||
|                     continue | ||||
|                 # Skip matching which have already been matched together | ||||
|                 # doc 1 to doc 2 is the same as doc 2 to doc 1 | ||||
|                 doc_1_to_doc_2 = (first_doc.pk, second_doc.pk) | ||||
|   | ||||
| @@ -314,11 +314,19 @@ def consumable_document_matches_workflow( | ||||
|         trigger_matched = False | ||||
|  | ||||
|     # Document path vs trigger path | ||||
|  | ||||
|     # Use the original_path if set, else us the original_file | ||||
|     match_against = ( | ||||
|         document.original_path | ||||
|         if document.original_path is not None | ||||
|         else document.original_file | ||||
|     ) | ||||
|  | ||||
|     if ( | ||||
|         trigger.filter_path is not None | ||||
|         and len(trigger.filter_path) > 0 | ||||
|         and not fnmatch( | ||||
|             document.original_file, | ||||
|             match_against, | ||||
|             trigger.filter_path, | ||||
|         ) | ||||
|     ): | ||||
|   | ||||
| @@ -614,14 +614,16 @@ class TestBarcodeNewConsume( | ||||
|             self.assertIsNotFile(temp_copy) | ||||
|  | ||||
|             # Check the split files exist | ||||
|             # Check the original_path is set | ||||
|             # Check the source is unchanged | ||||
|             # Check the overrides are unchanged | ||||
|             for ( | ||||
|                 new_input_doc, | ||||
|                 new_doc_overrides, | ||||
|             ) in self.get_all_consume_delay_call_args(): | ||||
|                 self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) | ||||
|                 self.assertIsFile(new_input_doc.original_file) | ||||
|                 self.assertEqual(new_input_doc.original_path, temp_copy) | ||||
|                 self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) | ||||
|                 self.assertEqual(overrides, new_doc_overrides) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -209,6 +209,26 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase): | ||||
|         # assert that we have an error logged with this invalid file. | ||||
|         error_logger.assert_called_once() | ||||
|  | ||||
|     @mock.patch("documents.management.commands.document_consumer.logger.warning") | ||||
|     def test_permission_error_on_prechecks(self, warning_logger): | ||||
|         filepath = Path(self.dirs.consumption_dir) / "selinux.txt" | ||||
|         filepath.touch() | ||||
|  | ||||
|         original_stat = Path.stat | ||||
|  | ||||
|         def raising_stat(self, *args, **kwargs): | ||||
|             if self == filepath: | ||||
|                 raise PermissionError("Permission denied") | ||||
|             return original_stat(self, *args, **kwargs) | ||||
|  | ||||
|         with mock.patch("pathlib.Path.stat", new=raising_stat): | ||||
|             document_consumer._consume(filepath) | ||||
|  | ||||
|         warning_logger.assert_called_once() | ||||
|         (args, _) = warning_logger.call_args | ||||
|         self.assertIn("Permission denied", args[0]) | ||||
|         self.consume_file_mock.assert_not_called() | ||||
|  | ||||
|     @override_settings(CONSUMPTION_DIR="does_not_exist") | ||||
|     def test_consumption_directory_invalid(self): | ||||
|         self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot") | ||||
|   | ||||
| @@ -206,3 +206,29 @@ class TestFuzzyMatchCommand(TestCase): | ||||
|         self.assertEqual(Document.objects.count(), 2) | ||||
|         self.assertIsNotNone(Document.objects.get(pk=1)) | ||||
|         self.assertIsNotNone(Document.objects.get(pk=2)) | ||||
|  | ||||
|     def test_empty_content(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - 2 documents exist, content is empty (pw-protected) | ||||
|         WHEN: | ||||
|             - Command is called | ||||
|         THEN: | ||||
|             - No matches are found | ||||
|         """ | ||||
|         Document.objects.create( | ||||
|             checksum="BEEFCAFE", | ||||
|             title="A", | ||||
|             content="", | ||||
|             mime_type="application/pdf", | ||||
|             filename="test.pdf", | ||||
|         ) | ||||
|         Document.objects.create( | ||||
|             checksum="DEADBEAF", | ||||
|             title="A", | ||||
|             content="", | ||||
|             mime_type="application/pdf", | ||||
|             filename="other_test.pdf", | ||||
|         ) | ||||
|         stdout, _ = self.call_command() | ||||
|         self.assertIn("No matches found", stdout) | ||||
|   | ||||
| @@ -2,7 +2,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: paperless-ngx\n" | ||||
| "Report-Msgid-Bugs-To: \n" | ||||
| "POT-Creation-Date: 2025-09-17 22:44+0000\n" | ||||
| "POT-Creation-Date: 2025-09-22 18:20+0000\n" | ||||
| "PO-Revision-Date: 2022-02-17 04:17\n" | ||||
| "Last-Translator: \n" | ||||
| "Language-Team: English\n" | ||||
| @@ -1827,7 +1827,7 @@ msgstr "" | ||||
| msgid "Chinese Traditional" | ||||
| msgstr "" | ||||
|  | ||||
| #: paperless/urls.py:368 | ||||
| #: paperless/urls.py:370 | ||||
| msgid "Paperless-ngx administration" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"] | ||||
| CELERY_BEAT_SCHEDULE = _parse_beat_schedule() | ||||
|  | ||||
| # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename | ||||
| CELERY_BEAT_SCHEDULE_FILENAME = DATA_DIR / "celerybeat-schedule.db" | ||||
| CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db") | ||||
|  | ||||
|  | ||||
| # Cachalot: Database read cache. | ||||
|   | ||||
| @@ -57,6 +57,7 @@ from paperless.views import UserViewSet | ||||
| from paperless_mail.views import MailAccountViewSet | ||||
| from paperless_mail.views import MailRuleViewSet | ||||
| from paperless_mail.views import OauthCallbackView | ||||
| from paperless_mail.views import ProcessedMailViewSet | ||||
|  | ||||
| api_router = DefaultRouter() | ||||
| api_router.register(r"correspondents", CorrespondentViewSet) | ||||
| @@ -77,6 +78,7 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet) | ||||
| api_router.register(r"workflows", WorkflowViewSet) | ||||
| api_router.register(r"custom_fields", CustomFieldViewSet) | ||||
| api_router.register(r"config", ApplicationConfigurationViewSet) | ||||
| api_router.register(r"processed_mail", ProcessedMailViewSet) | ||||
|  | ||||
|  | ||||
| urlpatterns = [ | ||||
|   | ||||
							
								
								
									
										12
									
								
								src/paperless_mail/filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/paperless_mail/filters.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| from django_filters import FilterSet | ||||
|  | ||||
| from paperless_mail.models import ProcessedMail | ||||
|  | ||||
|  | ||||
| class ProcessedMailFilterSet(FilterSet): | ||||
|     class Meta: | ||||
|         model = ProcessedMail | ||||
|         fields = { | ||||
|             "rule": ["exact"], | ||||
|             "status": ["exact"], | ||||
|         } | ||||
| @@ -6,6 +6,7 @@ from documents.serialisers import OwnedObjectSerializer | ||||
| from documents.serialisers import TagsField | ||||
| from paperless_mail.models import MailAccount | ||||
| from paperless_mail.models import MailRule | ||||
| from paperless_mail.models import ProcessedMail | ||||
|  | ||||
|  | ||||
| class ObfuscatedPasswordField(serializers.CharField): | ||||
| @@ -130,3 +131,20 @@ class MailRuleSerializer(OwnedObjectSerializer): | ||||
|         if value > 36500:  # ~100 years | ||||
|             raise serializers.ValidationError("Maximum mail age is unreasonably large.") | ||||
|         return value | ||||
|  | ||||
|  | ||||
| class ProcessedMailSerializer(OwnedObjectSerializer): | ||||
|     class Meta: | ||||
|         model = ProcessedMail | ||||
|         fields = [ | ||||
|             "id", | ||||
|             "owner", | ||||
|             "rule", | ||||
|             "folder", | ||||
|             "uid", | ||||
|             "subject", | ||||
|             "received", | ||||
|             "processed", | ||||
|             "status", | ||||
|             "error", | ||||
|         ] | ||||
|   | ||||
| @@ -3,6 +3,7 @@ from unittest import mock | ||||
|  | ||||
| from django.contrib.auth.models import Permission | ||||
| from django.contrib.auth.models import User | ||||
| from django.utils import timezone | ||||
| from guardian.shortcuts import assign_perm | ||||
| from rest_framework import status | ||||
| from rest_framework.test import APITestCase | ||||
| @@ -13,6 +14,7 @@ from documents.models import Tag | ||||
| from documents.tests.utils import DirectoriesMixin | ||||
| from paperless_mail.models import MailAccount | ||||
| from paperless_mail.models import MailRule | ||||
| from paperless_mail.models import ProcessedMail | ||||
| from paperless_mail.tests.test_mail import BogusMailBox | ||||
|  | ||||
|  | ||||
| @@ -721,3 +723,285 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|         self.assertIn("maximum_age", response.data) | ||||
|  | ||||
|  | ||||
| class TestAPIProcessedMails(DirectoriesMixin, APITestCase): | ||||
|     ENDPOINT = "/api/processed_mail/" | ||||
|  | ||||
|     def setUp(self): | ||||
|         super().setUp() | ||||
|  | ||||
|         self.user = User.objects.create_user(username="temp_admin") | ||||
|         self.user.user_permissions.add(*Permission.objects.all()) | ||||
|         self.user.save() | ||||
|         self.client.force_authenticate(user=self.user) | ||||
|  | ||||
|     def test_get_processed_mails_owner_aware(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Configured processed mails with different users | ||||
|         WHEN: | ||||
|             - API call is made to get processed mails | ||||
|         THEN: | ||||
|             - Only unowned, owned by user or granted processed mails are provided | ||||
|         """ | ||||
|         user2 = User.objects.create_user(username="temp_admin2") | ||||
|  | ||||
|         account = MailAccount.objects.create( | ||||
|             name="Email1", | ||||
|             username="username1", | ||||
|             password="password1", | ||||
|             imap_server="server.example.com", | ||||
|             imap_port=443, | ||||
|             imap_security=MailAccount.ImapSecurity.SSL, | ||||
|             character_set="UTF-8", | ||||
|         ) | ||||
|  | ||||
|         rule = MailRule.objects.create( | ||||
|             name="Rule1", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from@example.com", | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|         pm1 = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="1", | ||||
|             subject="Subj1", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|  | ||||
|         pm2 = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="2", | ||||
|             subject="Subj2", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="FAILED", | ||||
|             error="err", | ||||
|             owner=self.user, | ||||
|         ) | ||||
|  | ||||
|         ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="3", | ||||
|             subject="Subj3", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=user2, | ||||
|         ) | ||||
|  | ||||
|         pm4 = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="4", | ||||
|             subject="Subj4", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|         pm4.owner = user2 | ||||
|         pm4.save() | ||||
|         assign_perm("view_processedmail", self.user, pm4) | ||||
|  | ||||
|         response = self.client.get(self.ENDPOINT) | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         self.assertEqual(response.data["count"], 3) | ||||
|         returned_ids = {r["id"] for r in response.data["results"]} | ||||
|         self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id}) | ||||
|  | ||||
|     def test_get_processed_mails_filter_by_rule(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Processed mails belonging to two different rules | ||||
|         WHEN: | ||||
|             - API call is made with rule filter | ||||
|         THEN: | ||||
|             - Only processed mails for that rule are returned | ||||
|         """ | ||||
|         account = MailAccount.objects.create( | ||||
|             name="Email1", | ||||
|             username="username1", | ||||
|             password="password1", | ||||
|             imap_server="server.example.com", | ||||
|             imap_port=443, | ||||
|             imap_security=MailAccount.ImapSecurity.SSL, | ||||
|             character_set="UTF-8", | ||||
|         ) | ||||
|  | ||||
|         rule1 = MailRule.objects.create( | ||||
|             name="Rule1", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from1@example.com", | ||||
|             order=0, | ||||
|         ) | ||||
|         rule2 = MailRule.objects.create( | ||||
|             name="Rule2", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from2@example.com", | ||||
|             order=1, | ||||
|         ) | ||||
|  | ||||
|         pm1 = ProcessedMail.objects.create( | ||||
|             rule=rule1, | ||||
|             folder="INBOX", | ||||
|             uid="r1-1", | ||||
|             subject="R1-A", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=self.user, | ||||
|         ) | ||||
|         pm2 = ProcessedMail.objects.create( | ||||
|             rule=rule1, | ||||
|             folder="INBOX", | ||||
|             uid="r1-2", | ||||
|             subject="R1-B", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="FAILED", | ||||
|             error="e", | ||||
|         ) | ||||
|         ProcessedMail.objects.create( | ||||
|             rule=rule2, | ||||
|             folder="INBOX", | ||||
|             uid="r2-1", | ||||
|             subject="R2-A", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|  | ||||
|         response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}") | ||||
|  | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         returned_ids = {r["id"] for r in response.data["results"]} | ||||
|         self.assertSetEqual(returned_ids, {pm1.id, pm2.id}) | ||||
|  | ||||
|     def test_bulk_delete_processed_mails(self): | ||||
|         """ | ||||
|         GIVEN: | ||||
|             - Processed mails belonging to two different rules and different users | ||||
|         WHEN: | ||||
|             - API call is made to bulk delete some of the processed mails | ||||
|         THEN: | ||||
|             - Only the specified processed mails are deleted, respecting ownership and permissions | ||||
|         """ | ||||
|         user2 = User.objects.create_user(username="temp_admin2") | ||||
|  | ||||
|         account = MailAccount.objects.create( | ||||
|             name="Email1", | ||||
|             username="username1", | ||||
|             password="password1", | ||||
|             imap_server="server.example.com", | ||||
|             imap_port=443, | ||||
|             imap_security=MailAccount.ImapSecurity.SSL, | ||||
|             character_set="UTF-8", | ||||
|         ) | ||||
|  | ||||
|         rule = MailRule.objects.create( | ||||
|             name="Rule1", | ||||
|             account=account, | ||||
|             folder="INBOX", | ||||
|             filter_from="from@example.com", | ||||
|             order=0, | ||||
|         ) | ||||
|  | ||||
|         # unowned and owned by self, and one with explicit object perm | ||||
|         pm_unowned = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u1", | ||||
|             subject="Unowned", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|         ) | ||||
|         pm_owned = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u2", | ||||
|             subject="Owned", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="FAILED", | ||||
|             error="e", | ||||
|             owner=self.user, | ||||
|         ) | ||||
|         pm_granted = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u3", | ||||
|             subject="Granted", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=user2, | ||||
|         ) | ||||
|         assign_perm("delete_processedmail", self.user, pm_granted) | ||||
|         pm_forbidden = ProcessedMail.objects.create( | ||||
|             rule=rule, | ||||
|             folder="INBOX", | ||||
|             uid="u4", | ||||
|             subject="Forbidden", | ||||
|             received=timezone.now(), | ||||
|             processed=timezone.now(), | ||||
|             status="SUCCESS", | ||||
|             error=None, | ||||
|             owner=user2, | ||||
|         ) | ||||
|  | ||||
|         # Success for allowed items | ||||
|         response = self.client.post( | ||||
|             f"{self.ENDPOINT}bulk_delete/", | ||||
|             data={ | ||||
|                 "mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||
|         self.assertEqual(response.data["result"], "OK") | ||||
|         self.assertSetEqual( | ||||
|             set(response.data["deleted_mail_ids"]), | ||||
|             {pm_unowned.id, pm_owned.id, pm_granted.id}, | ||||
|         ) | ||||
|         self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists()) | ||||
|         self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists()) | ||||
|         self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists()) | ||||
|         self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) | ||||
|  | ||||
|         # 403 and not deleted | ||||
|         response = self.client.post( | ||||
|             f"{self.ENDPOINT}bulk_delete/", | ||||
|             data={ | ||||
|                 "mail_ids": [pm_forbidden.id], | ||||
|             }, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) | ||||
|         self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) | ||||
|  | ||||
|         # missing mail_ids | ||||
|         response = self.client.post( | ||||
|             f"{self.ENDPOINT}bulk_delete/", | ||||
|             data={"mail_ids": "not-a-list"}, | ||||
|             format="json", | ||||
|         ) | ||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||
|   | ||||
| @@ -3,8 +3,10 @@ import logging | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.http import HttpResponseBadRequest | ||||
| from django.http import HttpResponseForbidden | ||||
| from django.http import HttpResponseRedirect | ||||
| from django.utils import timezone | ||||
| from django_filters.rest_framework import DjangoFilterBackend | ||||
| from drf_spectacular.types import OpenApiTypes | ||||
| from drf_spectacular.utils import extend_schema | ||||
| from drf_spectacular.utils import extend_schema_view | ||||
| @@ -12,23 +14,29 @@ from drf_spectacular.utils import inline_serializer | ||||
| from httpx_oauth.oauth2 import GetAccessTokenError | ||||
| from rest_framework import serializers | ||||
| from rest_framework.decorators import action | ||||
| from rest_framework.filters import OrderingFilter | ||||
| from rest_framework.generics import GenericAPIView | ||||
| from rest_framework.permissions import IsAuthenticated | ||||
| from rest_framework.response import Response | ||||
| from rest_framework.viewsets import ModelViewSet | ||||
| from rest_framework.viewsets import ReadOnlyModelViewSet | ||||
|  | ||||
| from documents.filters import ObjectOwnedOrGrantedPermissionsFilter | ||||
| from documents.permissions import PaperlessObjectPermissions | ||||
| from documents.permissions import has_perms_owner_aware | ||||
| from documents.views import PassUserMixin | ||||
| from paperless.views import StandardPagination | ||||
| from paperless_mail.filters import ProcessedMailFilterSet | ||||
| from paperless_mail.mail import MailError | ||||
| from paperless_mail.mail import get_mailbox | ||||
| from paperless_mail.mail import mailbox_login | ||||
| from paperless_mail.models import MailAccount | ||||
| from paperless_mail.models import MailRule | ||||
| from paperless_mail.models import ProcessedMail | ||||
| from paperless_mail.oauth import PaperlessMailOAuth2Manager | ||||
| from paperless_mail.serialisers import MailAccountSerializer | ||||
| from paperless_mail.serialisers import MailRuleSerializer | ||||
| from paperless_mail.serialisers import ProcessedMailSerializer | ||||
| from paperless_mail.tasks import process_mail_accounts | ||||
|  | ||||
|  | ||||
| @@ -126,6 +134,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): | ||||
|         return Response({"result": "OK"}) | ||||
|  | ||||
|  | ||||
| class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin): | ||||
|     permission_classes = (IsAuthenticated, PaperlessObjectPermissions) | ||||
|     serializer_class = ProcessedMailSerializer | ||||
|     pagination_class = StandardPagination | ||||
|     filter_backends = ( | ||||
|         DjangoFilterBackend, | ||||
|         OrderingFilter, | ||||
|         ObjectOwnedOrGrantedPermissionsFilter, | ||||
|     ) | ||||
|     filterset_class = ProcessedMailFilterSet | ||||
|  | ||||
|     queryset = ProcessedMail.objects.all().order_by("-processed") | ||||
|  | ||||
|     @action(methods=["post"], detail=False) | ||||
|     def bulk_delete(self, request): | ||||
|         mail_ids = request.data.get("mail_ids", []) | ||||
|         if not isinstance(mail_ids, list) or not all( | ||||
|             isinstance(i, int) for i in mail_ids | ||||
|         ): | ||||
|             return HttpResponseBadRequest("mail_ids must be a list of integers") | ||||
|         mails = ProcessedMail.objects.filter(id__in=mail_ids) | ||||
|         for mail in mails: | ||||
|             if not has_perms_owner_aware(request.user, "delete_processedmail", mail): | ||||
|                 return HttpResponseForbidden("Insufficient permissions") | ||||
|             mail.delete() | ||||
|         return Response({"result": "OK", "deleted_mail_ids": mail_ids}) | ||||
|  | ||||
|  | ||||
| class MailRuleViewSet(ModelViewSet, PassUserMixin): | ||||
|     model = MailRule | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user