mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-24 01:02:45 -05:00
Compare commits
8 Commits
fix-strip-
...
dependabot
Author | SHA1 | Date | |
---|---|---|---|
![]() |
c212c823f6 | ||
![]() |
8d1f23e9d6 | ||
![]() |
c8850fa752 | ||
![]() |
19a54b3b23 | ||
![]() |
1cdd8d9ba8 | ||
![]() |
4449dbadb5 | ||
![]() |
0e35acaef5 | ||
![]() |
19ff339804 |
91
.github/workflows/ci.yml
vendored
91
.github/workflows/ci.yml
vendored
@@ -151,6 +151,18 @@ jobs:
|
|||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: backend-coverage-${{ matrix.python-version }}
|
||||||
|
path: |
|
||||||
|
.coverage
|
||||||
|
coverage.xml
|
||||||
|
junit.xml
|
||||||
|
retention-days: 1
|
||||||
|
include-hidden-files: true
|
||||||
|
if-no-files-found: error
|
||||||
- name: Stop containers
|
- name: Stop containers
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
@@ -233,6 +245,17 @@ jobs:
|
|||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always()
|
||||||
|
with:
|
||||||
|
name: frontend-coverage-${{ matrix.shard-index }}
|
||||||
|
path: |
|
||||||
|
src-ui/coverage/lcov.info
|
||||||
|
src-ui/coverage/coverage-final.json
|
||||||
|
src-ui/junit.xml
|
||||||
|
retention-days: 1
|
||||||
|
if-no-files-found: error
|
||||||
tests-frontend-e2e:
|
tests-frontend-e2e:
|
||||||
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: "Frontend E2E Tests (Node ${{ matrix.node-version }} - ${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -313,6 +336,74 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
|
sonarqube-analysis:
|
||||||
|
name: "SonarQube Analysis"
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs:
|
||||||
|
- tests-backend
|
||||||
|
- tests-frontend
|
||||||
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Download all backend coverage
|
||||||
|
uses: actions/download-artifact@v5.0.0
|
||||||
|
with:
|
||||||
|
pattern: backend-coverage-*
|
||||||
|
path: ./coverage/
|
||||||
|
- name: Download all frontend coverage
|
||||||
|
uses: actions/download-artifact@v5.0.0
|
||||||
|
with:
|
||||||
|
pattern: frontend-coverage-*
|
||||||
|
path: ./coverage/
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install coverage tools
|
||||||
|
run: |
|
||||||
|
pip install coverage
|
||||||
|
npm install -g nyc
|
||||||
|
# Merge backend coverage from all Python versions
|
||||||
|
- name: Merge backend coverage
|
||||||
|
run: |
|
||||||
|
coverage combine coverage/backend-coverage-*/.coverage
|
||||||
|
coverage xml -o merged-backend-coverage.xml
|
||||||
|
# Merge frontend coverage from all shards
|
||||||
|
- name: Merge frontend coverage
|
||||||
|
run: |
|
||||||
|
# Find all coverage-final.json files from the shards, exit with error if none found
|
||||||
|
shopt -s nullglob
|
||||||
|
files=(coverage/frontend-coverage-*/coverage/coverage-final.json)
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "No frontend coverage JSON found under coverage/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Create .nyc_output directory and copy each shard's coverage JSON into it with a unique name
|
||||||
|
mkdir -p .nyc_output
|
||||||
|
for coverage_json in "${files[@]}"; do
|
||||||
|
shard=$(basename "$(dirname "$(dirname "$coverage_json")")")
|
||||||
|
cp "$coverage_json" ".nyc_output/${shard}.json"
|
||||||
|
done
|
||||||
|
npx nyc merge .nyc_output .nyc_output/out.json
|
||||||
|
npx nyc report --reporter=lcovonly --report-dir coverage
|
||||||
|
- name: Upload coverage artifacts
|
||||||
|
uses: actions/upload-artifact@v4.6.2
|
||||||
|
with:
|
||||||
|
name: merged-coverage
|
||||||
|
path: |
|
||||||
|
merged-backend-coverage.xml
|
||||||
|
.nyc_output/*
|
||||||
|
coverage/lcov.info
|
||||||
|
retention-days: 7
|
||||||
|
if-no-files-found: error
|
||||||
|
include-hidden-files: true
|
||||||
|
- name: SonarQube Analysis
|
||||||
|
uses: SonarSource/sonarqube-scan-action@v5
|
||||||
|
env:
|
||||||
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
build-docker-image:
|
build-docker-image:
|
||||||
name: Build Docker image for ${{ github.ref_name }}
|
name: Build Docker image for ${{ github.ref_name }}
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
|
1
.github/workflows/repo-maintenance.yml
vendored
1
.github/workflows/repo-maintenance.yml
vendored
@@ -241,6 +241,7 @@ jobs:
|
|||||||
) {
|
) {
|
||||||
nodes {
|
nodes {
|
||||||
id,
|
id,
|
||||||
|
createdAt,
|
||||||
number,
|
number,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
upvoteCount,
|
upvoteCount,
|
||||||
|
@@ -135,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.
|
- 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 with a marked answer will be automatically closed.
|
||||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
- 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.
|
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.
|
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`
|
: 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}
|
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
||||||
|
|
||||||
!!! note
|
!!! 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
|
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)
|
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
|
#### 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.
|
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.
|
||||||
|
@@ -33,7 +33,7 @@ dependencies = [
|
|||||||
"django-cors-headers~=4.8.0",
|
"django-cors-headers~=4.8.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.1.2",
|
"django-guardian~=3.2.0",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.23.2",
|
"django-treenode>=0.23.2",
|
||||||
@@ -255,6 +255,7 @@ PAPERLESS_DISABLE_DBHANDLER = "true"
|
|||||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
|
relative_files = true
|
||||||
source = [
|
source = [
|
||||||
"src/",
|
"src/",
|
||||||
]
|
]
|
||||||
|
24
sonar-project.properties
Normal file
24
sonar-project.properties
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
sonar.projectKey=paperless-ngx_paperless-ngx
|
||||||
|
sonar.organization=paperless-ngx
|
||||||
|
sonar.projectName=Paperless-ngx
|
||||||
|
sonar.projectVersion=1.0
|
||||||
|
|
||||||
|
# Source and test directories
|
||||||
|
sonar.sources=src/,src-ui/
|
||||||
|
sonar.test.inclusions=**/test_*.py,**/tests.py,**/*.spec.ts,**/*.test.ts
|
||||||
|
|
||||||
|
# Language specific settings
|
||||||
|
sonar.python.version=3.10,3.11,3.12,3.13
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
sonar.python.coverage.reportPaths=merged-backend-coverage.xml
|
||||||
|
sonar.javascript.lcov.reportPaths=coverage/lcov.info
|
||||||
|
|
||||||
|
# Test execution reports
|
||||||
|
sonar.junit.reportPaths=**/junit.xml,**/test-results.xml
|
||||||
|
|
||||||
|
# Encoding
|
||||||
|
sonar.sourceEncoding=UTF-8
|
||||||
|
|
||||||
|
# Exclusions
|
||||||
|
sonar.exclusions=**/migrations/**,**/node_modules/**,**/static/**,**/venv/**,**/.venv/**,**/dist/**
|
@@ -755,11 +755,15 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<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="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||||
<context context-type="linenumber">4</context>
|
<context context-type="linenumber">4</context>
|
||||||
</context-group>
|
</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>
|
||||||
<trans-unit id="6226301160429720843" datatype="html">
|
<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>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -1812,7 +1820,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<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="sourcefile">src/app/components/admin/trash/trash.component.html</context>
|
||||||
<context context-type="linenumber">14</context>
|
<context context-type="linenumber">14</context>
|
||||||
</context-group>
|
</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>
|
||||||
<trans-unit id="8597030111956627342" datatype="html">
|
<trans-unit id="8597030111956627342" datatype="html">
|
||||||
<source>Empty trash</source>
|
<source>Empty trash</source>
|
||||||
@@ -2113,11 +2129,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -2241,11 +2257,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
||||||
@@ -2432,11 +2448,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.html</context>
|
||||||
@@ -2568,11 +2584,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
<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="sourcefile">src/app/components/common/clearable-badge/clearable-badge.component.html</context>
|
||||||
<context context-type="linenumber">2</context>
|
<context context-type="linenumber">2</context>
|
||||||
</context-group>
|
</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>
|
||||||
<trans-unit id="7515883357904500238" datatype="html">
|
<trans-unit id="7515883357904500238" datatype="html">
|
||||||
<source>Are you sure?</source>
|
<source>Are you sure?</source>
|
||||||
@@ -3896,7 +3916,7 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<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="sourcefile">src/app/components/common/toast/toast.component.html</context>
|
||||||
<context context-type="linenumber">30</context>
|
<context context-type="linenumber">30</context>
|
||||||
</context-group>
|
</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>
|
||||||
<trans-unit id="6886003843406464884" datatype="html">
|
<trans-unit id="6886003843406464884" datatype="html">
|
||||||
<source>Only process attachments</source>
|
<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="sourcefile">src/app/components/common/email-document-dialog/email-document-dialog.component.html</context>
|
||||||
<context context-type="linenumber">11</context>
|
<context context-type="linenumber">11</context>
|
||||||
</context-group>
|
</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>
|
||||||
<trans-unit id="8066608938393600549" datatype="html">
|
<trans-unit id="8066608938393600549" datatype="html">
|
||||||
<source>Message</source>
|
<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="sourcefile">src/app/components/common/permissions-select/permissions-select.component.html</context>
|
||||||
<context context-type="linenumber">9</context>
|
<context context-type="linenumber">9</context>
|
||||||
</context-group>
|
</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>
|
||||||
<trans-unit id="5034217198277582100" datatype="html">
|
<trans-unit id="5034217198277582100" datatype="html">
|
||||||
<source>Select all pages</source>
|
<source>Select all pages</source>
|
||||||
@@ -5745,11 +5777,11 @@
|
|||||||
</context-group>
|
</context-group>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<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="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
||||||
<context context-type="linenumber">114</context>
|
<context context-type="linenumber">114</context>
|
||||||
</context-group>
|
</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-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
<context context-type="linenumber">19</context>
|
<context context-type="linenumber">19</context>
|
||||||
@@ -8517,185 +8553,227 @@
|
|||||||
<source>Disabled</source>
|
<source>Disabled</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
<context context-type="sourcefile">src/app/components/manage/workflows/workflows.component.html</context>
|
||||||
<context context-type="linenumber">41</context>
|
<context context-type="linenumber">41</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="6751234988479444294" datatype="html">
|
||||||
<source>No mail rules defined.</source>
|
<source>No mail rules defined.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.html</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3178554336792037159" datatype="html">
|
<trans-unit id="3178554336792037159" datatype="html">
|
||||||
<source>Error retrieving mail accounts</source>
|
<source>Error retrieving mail accounts</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5241231471117657636" datatype="html">
|
<trans-unit id="5241231471117657636" datatype="html">
|
||||||
<source>Error retrieving mail rules</source>
|
<source>Error retrieving mail rules</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="763945516325093575" datatype="html">
|
<trans-unit id="763945516325093575" datatype="html">
|
||||||
<source>OAuth2 authentication success</source>
|
<source>OAuth2 authentication success</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="9022978370268070156" datatype="html">
|
<trans-unit id="9022978370268070156" datatype="html">
|
||||||
<source>OAuth2 authentication failed, see logs for details</source>
|
<source>OAuth2 authentication failed, see logs for details</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6327501535846658797" datatype="html">
|
<trans-unit id="6327501535846658797" datatype="html">
|
||||||
<source>Saved account "<x id="PH" equiv-text="newMailAccount.name"/>".</source>
|
<source>Saved account "<x id="PH" equiv-text="newMailAccount.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8067594003836508139" datatype="html">
|
<trans-unit id="8067594003836508139" datatype="html">
|
||||||
<source>Error saving account.</source>
|
<source>Error saving account.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5641934153807844674" datatype="html">
|
<trans-unit id="5641934153807844674" datatype="html">
|
||||||
<source>Confirm delete mail account</source>
|
<source>Confirm delete mail account</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7176985344323395435" datatype="html">
|
<trans-unit id="7176985344323395435" datatype="html">
|
||||||
<source>This operation will permanently delete this mail account.</source>
|
<source>This operation will permanently delete this mail account.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5876433590301754883" datatype="html">
|
<trans-unit id="5876433590301754883" datatype="html">
|
||||||
<source>Deleted mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
<source>Deleted mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="5981429299543258715" datatype="html">
|
<trans-unit id="5981429299543258715" datatype="html">
|
||||||
<source>Error deleting mail account "<x id="PH" equiv-text="account.name"/>".</source>
|
<source>Error deleting mail account "<x id="PH" equiv-text="account.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="6424800796582120505" datatype="html">
|
<trans-unit id="6424800796582120505" datatype="html">
|
||||||
<source>Processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
<source>Processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3138185874003827652" datatype="html">
|
<trans-unit id="3138185874003827652" datatype="html">
|
||||||
<source>Error processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
<source>Error processing mail account "<x id="PH" equiv-text="account.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="123368655395433699" datatype="html">
|
<trans-unit id="123368655395433699" datatype="html">
|
||||||
<source>Saved rule "<x id="PH" equiv-text="newMailRule.name"/>".</source>
|
<source>Saved rule "<x id="PH" equiv-text="newMailRule.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="8951124554918814321" datatype="html">
|
<trans-unit id="8951124554918814321" datatype="html">
|
||||||
<source>Error saving rule.</source>
|
<source>Error saving rule.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3574401690710711341" datatype="html">
|
<trans-unit id="3574401690710711341" datatype="html">
|
||||||
<source>Rule "<x id="PH" equiv-text="rule.name"/>" enabled.</source>
|
<source>Rule "<x id="PH" equiv-text="rule.name"/>" enabled.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7171685227222299542" datatype="html">
|
<trans-unit id="7171685227222299542" datatype="html">
|
||||||
<source>Rule "<x id="PH" equiv-text="rule.name"/>" disabled.</source>
|
<source>Rule "<x id="PH" equiv-text="rule.name"/>" disabled.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="7238791203524413596" datatype="html">
|
<trans-unit id="7238791203524413596" datatype="html">
|
||||||
<source>Error toggling rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
<source>Error toggling rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3896080636020672118" datatype="html">
|
<trans-unit id="3896080636020672118" datatype="html">
|
||||||
<source>Confirm delete mail rule</source>
|
<source>Confirm delete mail rule</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="2250372580580310337" datatype="html">
|
<trans-unit id="2250372580580310337" datatype="html">
|
||||||
<source>This operation will permanently delete this mail rule.</source>
|
<source>This operation will permanently delete this mail rule.</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4357654589451732716" datatype="html">
|
<trans-unit id="4357654589451732716" datatype="html">
|
||||||
<source>Deleted mail rule "<x id="PH" equiv-text="rule.name"/>"</source>
|
<source>Deleted mail rule "<x id="PH" equiv-text="rule.name"/>"</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="1696130068388341598" datatype="html">
|
<trans-unit id="1696130068388341598" datatype="html">
|
||||||
<source>Error deleting mail rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
<source>Error deleting mail rule "<x id="PH" equiv-text="rule.name"/>".</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="3061362835271417984" datatype="html">
|
<trans-unit id="3061362835271417984" datatype="html">
|
||||||
<source>Permissions updated</source>
|
<source>Permissions updated</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
</context-group>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="4639647950943944112" datatype="html">
|
<trans-unit id="4639647950943944112" datatype="html">
|
||||||
<source>Error updating permissions</source>
|
<source>Error updating permissions</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/mail/mail.component.ts</context>
|
<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>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
<context context-type="sourcefile">src/app/components/manage/management-list/management-list.component.ts</context>
|
||||||
<context context-type="linenumber">339</context>
|
<context context-type="linenumber">339</context>
|
||||||
</context-group>
|
</context-group>
|
||||||
</trans-unit>
|
</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">
|
<trans-unit id="4010735610815226758" datatype="html">
|
||||||
<source>Filter by:</source>
|
<source>Filter by:</source>
|
||||||
<context-group purpose="location">
|
<context-group purpose="location">
|
||||||
|
@@ -109,10 +109,11 @@
|
|||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col" i18n>Name</div>
|
<div class="col" i18n>Name</div>
|
||||||
<div class="col d-none d-sm-block" i18n>Sort Order</div>
|
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
|
||||||
<div class="col" i18n>Account</div>
|
<div class="col-2" i18n>Account</div>
|
||||||
<div class="col d-none d-sm-block" i18n>Status</div>
|
<div class="col-2 d-none d-sm-block" i18n>Status</div>
|
||||||
<div class="col" i18n>Actions</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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -127,9 +128,9 @@
|
|||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row fade" [class.show]="showRules">
|
<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"><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-1 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-2 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-2 d-flex align-items-center d-none d-sm-flex">
|
||||||
<div class="form-check form-switch mb-0">
|
<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 }">
|
<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'">
|
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
|
||||||
@@ -137,7 +138,12 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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 class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
@@ -409,4 +409,13 @@ describe('MailComponent', () => {
|
|||||||
jest.advanceTimersByTime(200)
|
jest.advanceTimersByTime(200)
|
||||||
expect(editSpy).toHaveBeenCalled()
|
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 { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-mail',
|
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 {
|
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||||
return this.permissionsService.currentUserHasObjectPermissions(
|
return this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
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"?'
|
'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[]) {
|
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',
|
ShareLink = '%s_sharelink',
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
|
ProcessedMail = '%s_processedmail',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@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,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircle,
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
@@ -60,6 +61,7 @@ import {
|
|||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
|
clockHistory,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -263,6 +265,7 @@ const icons = {
|
|||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircle,
|
||||||
checkCircleFill,
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
@@ -272,6 +275,7 @@ const icons = {
|
|||||||
clipboardCheck,
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
|
clockHistory,
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
|
@@ -82,6 +82,13 @@ def _is_ignored(filepath: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _consume(filepath: Path) -> None:
|
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):
|
if filepath.is_dir() or _is_ignored(filepath):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -323,7 +330,12 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Also make sure the file exists still, some scanners might write a
|
# Also make sure the file exists still, some scanners might write a
|
||||||
# temporary file first
|
# 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:
|
if waited_long_enough and file_still_exists:
|
||||||
_consume(filepath)
|
_consume(filepath)
|
||||||
|
@@ -1,6 +1,4 @@
|
|||||||
import json
|
import json
|
||||||
from fractions import Fraction
|
|
||||||
from io import BytesIO
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
@@ -8,11 +6,6 @@ from django.core.files.uploadedfile import SimpleUploadedFile
|
|||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
try:
|
|
||||||
from PIL import Image
|
|
||||||
except ModuleNotFoundError: # pragma: no cover - Pillow is required in production
|
|
||||||
Image = None # type: ignore[assignment]
|
|
||||||
|
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from paperless.models import ApplicationConfiguration
|
from paperless.models import ApplicationConfiguration
|
||||||
from paperless.models import ColorConvertChoices
|
from paperless.models import ColorConvertChoices
|
||||||
@@ -197,74 +190,6 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertFalse(Path(old_logo.path).exists())
|
self.assertFalse(Path(old_logo.path).exists())
|
||||||
|
|
||||||
def test_api_strips_metadata_from_logo_upload(self):
|
|
||||||
"""
|
|
||||||
GIVEN:
|
|
||||||
- An image file containing EXIF metadata including GPS coordinates
|
|
||||||
WHEN:
|
|
||||||
- Uploaded via PATCH to app config
|
|
||||||
THEN:
|
|
||||||
- Stored logo no longer contains EXIF metadata
|
|
||||||
"""
|
|
||||||
if Image is None:
|
|
||||||
self.skipTest("Pillow is not installed")
|
|
||||||
|
|
||||||
if not hasattr(Image, "Exif"):
|
|
||||||
self.skipTest("Current Pillow version cannot create EXIF metadata")
|
|
||||||
|
|
||||||
assert Image is not None
|
|
||||||
|
|
||||||
exif = Image.Exif()
|
|
||||||
exif[0x010E] = "Test description" # ImageDescription
|
|
||||||
exif[0x8825] = {
|
|
||||||
1: "N", # GPSLatitudeRef
|
|
||||||
2: (Fraction(51, 1), Fraction(30, 1), Fraction(0, 1)),
|
|
||||||
3: "E", # GPSLongitudeRef
|
|
||||||
4: (Fraction(0, 1), Fraction(7, 1), Fraction(0, 1)),
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer = BytesIO()
|
|
||||||
Image.new("RGB", (8, 8), "white").save(buffer, format="JPEG", exif=exif)
|
|
||||||
buffer.seek(0)
|
|
||||||
|
|
||||||
with Image.open(BytesIO(buffer.getvalue())) as uploaded_image:
|
|
||||||
self.assertGreater(len(uploaded_image.getexif()), 0)
|
|
||||||
|
|
||||||
response = self.client.patch(
|
|
||||||
f"{self.ENDPOINT}1/",
|
|
||||||
{
|
|
||||||
"app_logo": SimpleUploadedFile(
|
|
||||||
name="with_exif.jpg",
|
|
||||||
content=buffer.getvalue(),
|
|
||||||
content_type="image/jpeg",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
config = ApplicationConfiguration.objects.first()
|
|
||||||
stored_logo = Path(config.app_logo.path)
|
|
||||||
self.assertTrue(stored_logo.exists())
|
|
||||||
|
|
||||||
with Image.open(stored_logo) as sanitized:
|
|
||||||
sanitized_exif = sanitized.getexif()
|
|
||||||
self.assertNotEqual(sanitized_exif.get(0x010E), "Test description")
|
|
||||||
|
|
||||||
gps_ifd = None
|
|
||||||
if hasattr(sanitized_exif, "get_ifd"):
|
|
||||||
try:
|
|
||||||
gps_ifd = sanitized_exif.get_ifd(0x8825)
|
|
||||||
except KeyError:
|
|
||||||
gps_ifd = None
|
|
||||||
else:
|
|
||||||
gps_ifd = sanitized_exif.get(0x8825)
|
|
||||||
|
|
||||||
if gps_ifd is not None:
|
|
||||||
self.assertEqual(len(gps_ifd), 0, "GPS metadata should be cleared")
|
|
||||||
|
|
||||||
self.assertNotIn("exif", sanitized.info)
|
|
||||||
|
|
||||||
def test_api_rejects_malicious_svg_logo(self):
|
def test_api_rejects_malicious_svg_logo(self):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
@@ -209,6 +209,26 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase):
|
|||||||
# assert that we have an error logged with this invalid file.
|
# assert that we have an error logged with this invalid file.
|
||||||
error_logger.assert_called_once()
|
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")
|
@override_settings(CONSUMPTION_DIR="does_not_exist")
|
||||||
def test_consumption_directory_invalid(self):
|
def test_consumption_directory_invalid(self):
|
||||||
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")
|
self.assertRaises(CommandError, call_command, "document_consumer", "--oneshot")
|
||||||
|
@@ -2,7 +2,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: paperless-ngx\n"
|
"Project-Id-Version: paperless-ngx\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: 2022-02-17 04:17\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: English\n"
|
"Language-Team: English\n"
|
||||||
@@ -1827,7 +1827,7 @@ msgstr ""
|
|||||||
msgid "Chinese Traditional"
|
msgid "Chinese Traditional"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: paperless/urls.py:368
|
#: paperless/urls.py:370
|
||||||
msgid "Paperless-ngx administration"
|
msgid "Paperless-ngx administration"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
import logging
|
import logging
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
import magic
|
import magic
|
||||||
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
from allauth.mfa.adapter import get_adapter as get_mfa_adapter
|
||||||
@@ -10,10 +9,6 @@ from allauth.socialaccount.models import SocialApp
|
|||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
||||||
from PIL import Image
|
|
||||||
from PIL import ImageOps
|
|
||||||
from PIL import UnidentifiedImageError
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.authtoken.serializers import AuthTokenSerializer
|
from rest_framework.authtoken.serializers import AuthTokenSerializer
|
||||||
|
|
||||||
@@ -24,102 +19,6 @@ from paperless_mail.serialisers import ObfuscatedPasswordField
|
|||||||
logger = logging.getLogger("paperless.settings")
|
logger = logging.getLogger("paperless.settings")
|
||||||
|
|
||||||
|
|
||||||
def strip_image_metadata(uploaded_file, mime_type: str | None):
|
|
||||||
"""Return a copy of ``uploaded_file`` with EXIF/ICC metadata removed."""
|
|
||||||
|
|
||||||
if uploaded_file is None:
|
|
||||||
return uploaded_file
|
|
||||||
|
|
||||||
original_position = uploaded_file.tell() if hasattr(uploaded_file, "tell") else None
|
|
||||||
image = None
|
|
||||||
|
|
||||||
sanitized = None
|
|
||||||
|
|
||||||
try:
|
|
||||||
if hasattr(uploaded_file, "seek"):
|
|
||||||
uploaded_file.seek(0)
|
|
||||||
image = Image.open(uploaded_file)
|
|
||||||
image.load()
|
|
||||||
except (UnidentifiedImageError, OSError):
|
|
||||||
if hasattr(uploaded_file, "seek") and original_position is not None:
|
|
||||||
uploaded_file.seek(original_position)
|
|
||||||
return uploaded_file
|
|
||||||
|
|
||||||
try:
|
|
||||||
image_format = (image.format or "").upper()
|
|
||||||
image = ImageOps.exif_transpose(image)
|
|
||||||
|
|
||||||
if image_format not in {"JPEG", "JPG", "PNG"}:
|
|
||||||
if hasattr(uploaded_file, "seek") and original_position is not None:
|
|
||||||
uploaded_file.seek(original_position)
|
|
||||||
return uploaded_file
|
|
||||||
|
|
||||||
if hasattr(image, "info"):
|
|
||||||
image.info.pop("exif", None)
|
|
||||||
image.info.pop("icc_profile", None)
|
|
||||||
image.info.pop("comment", None)
|
|
||||||
|
|
||||||
if image_format in {"JPEG", "JPG"}:
|
|
||||||
sanitized = image.convert("RGB")
|
|
||||||
save_kwargs = {
|
|
||||||
"format": "JPEG",
|
|
||||||
"quality": 95,
|
|
||||||
"subsampling": 0,
|
|
||||||
"optimize": True,
|
|
||||||
"exif": b"",
|
|
||||||
}
|
|
||||||
else: # PNG
|
|
||||||
target_mode = (
|
|
||||||
"RGBA"
|
|
||||||
if ("A" in image.mode or image.info.get("transparency"))
|
|
||||||
else "RGB"
|
|
||||||
)
|
|
||||||
sanitized = image.convert(target_mode)
|
|
||||||
save_kwargs = {
|
|
||||||
"format": "PNG",
|
|
||||||
"optimize": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
buffer = BytesIO()
|
|
||||||
try:
|
|
||||||
sanitized.save(buffer, **save_kwargs)
|
|
||||||
except (OSError, ValueError):
|
|
||||||
buffer = BytesIO()
|
|
||||||
if image_format in {"JPEG", "JPG"}:
|
|
||||||
sanitized.save(
|
|
||||||
buffer,
|
|
||||||
format="JPEG",
|
|
||||||
quality=90,
|
|
||||||
subsampling=0,
|
|
||||||
exif=b"",
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
sanitized.save(
|
|
||||||
buffer,
|
|
||||||
format="PNG",
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer.seek(0)
|
|
||||||
|
|
||||||
if hasattr(uploaded_file, "close"):
|
|
||||||
try:
|
|
||||||
uploaded_file.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
content_type = getattr(uploaded_file, "content_type", None) or mime_type
|
|
||||||
return SimpleUploadedFile(
|
|
||||||
name=getattr(uploaded_file, "name", "logo"),
|
|
||||||
content=buffer.getvalue(),
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if sanitized is not None:
|
|
||||||
sanitized.close()
|
|
||||||
if image is not None:
|
|
||||||
image.close()
|
|
||||||
|
|
||||||
|
|
||||||
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
class PaperlessAuthTokenSerializer(AuthTokenSerializer):
|
||||||
code = serializers.CharField(
|
code = serializers.CharField(
|
||||||
label="MFA Code",
|
label="MFA Code",
|
||||||
@@ -310,22 +209,9 @@ class ApplicationConfigurationSerializer(serializers.ModelSerializer):
|
|||||||
return super().update(instance, validated_data)
|
return super().update(instance, validated_data)
|
||||||
|
|
||||||
def validate_app_logo(self, file):
|
def validate_app_logo(self, file):
|
||||||
if not file:
|
if file and magic.from_buffer(file.read(2048), mime=True) == "image/svg+xml":
|
||||||
return file
|
|
||||||
|
|
||||||
if hasattr(file, "seek"):
|
|
||||||
file.seek(0)
|
|
||||||
mime_type = magic.from_buffer(file.read(2048), mime=True)
|
|
||||||
if hasattr(file, "seek"):
|
|
||||||
file.seek(0)
|
|
||||||
|
|
||||||
if mime_type == "image/svg+xml":
|
|
||||||
reject_dangerous_svg(file)
|
reject_dangerous_svg(file)
|
||||||
if hasattr(file, "seek"):
|
return file
|
||||||
file.seek(0)
|
|
||||||
return file
|
|
||||||
|
|
||||||
return strip_image_metadata(file, mime_type)
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ApplicationConfiguration
|
model = ApplicationConfiguration
|
||||||
|
@@ -57,6 +57,7 @@ from paperless.views import UserViewSet
|
|||||||
from paperless_mail.views import MailAccountViewSet
|
from paperless_mail.views import MailAccountViewSet
|
||||||
from paperless_mail.views import MailRuleViewSet
|
from paperless_mail.views import MailRuleViewSet
|
||||||
from paperless_mail.views import OauthCallbackView
|
from paperless_mail.views import OauthCallbackView
|
||||||
|
from paperless_mail.views import ProcessedMailViewSet
|
||||||
|
|
||||||
api_router = DefaultRouter()
|
api_router = DefaultRouter()
|
||||||
api_router.register(r"correspondents", CorrespondentViewSet)
|
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"workflows", WorkflowViewSet)
|
||||||
api_router.register(r"custom_fields", CustomFieldViewSet)
|
api_router.register(r"custom_fields", CustomFieldViewSet)
|
||||||
api_router.register(r"config", ApplicationConfigurationViewSet)
|
api_router.register(r"config", ApplicationConfigurationViewSet)
|
||||||
|
api_router.register(r"processed_mail", ProcessedMailViewSet)
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
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 documents.serialisers import TagsField
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
from paperless_mail.models import ProcessedMail
|
||||||
|
|
||||||
|
|
||||||
class ObfuscatedPasswordField(serializers.CharField):
|
class ObfuscatedPasswordField(serializers.CharField):
|
||||||
@@ -130,3 +131,20 @@ class MailRuleSerializer(OwnedObjectSerializer):
|
|||||||
if value > 36500: # ~100 years
|
if value > 36500: # ~100 years
|
||||||
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
raise serializers.ValidationError("Maximum mail age is unreasonably large.")
|
||||||
return value
|
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 Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
from guardian.shortcuts import assign_perm
|
from guardian.shortcuts import assign_perm
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
@@ -13,6 +14,7 @@ from documents.models import Tag
|
|||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
from paperless_mail.models import ProcessedMail
|
||||||
from paperless_mail.tests.test_mail import BogusMailBox
|
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.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("maximum_age", response.data)
|
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 datetime import timedelta
|
||||||
|
|
||||||
from django.http import HttpResponseBadRequest
|
from django.http import HttpResponseBadRequest
|
||||||
|
from django.http import HttpResponseForbidden
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
from django_filters.rest_framework import DjangoFilterBackend
|
||||||
from drf_spectacular.types import OpenApiTypes
|
from drf_spectacular.types import OpenApiTypes
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from drf_spectacular.utils import extend_schema_view
|
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 httpx_oauth.oauth2 import GetAccessTokenError
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.filters import OrderingFilter
|
||||||
from rest_framework.generics import GenericAPIView
|
from rest_framework.generics import GenericAPIView
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework.permissions import IsAuthenticated
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.viewsets import ModelViewSet
|
from rest_framework.viewsets import ModelViewSet
|
||||||
|
from rest_framework.viewsets import ReadOnlyModelViewSet
|
||||||
|
|
||||||
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
|
||||||
from documents.permissions import PaperlessObjectPermissions
|
from documents.permissions import PaperlessObjectPermissions
|
||||||
|
from documents.permissions import has_perms_owner_aware
|
||||||
from documents.views import PassUserMixin
|
from documents.views import PassUserMixin
|
||||||
from paperless.views import StandardPagination
|
from paperless.views import StandardPagination
|
||||||
|
from paperless_mail.filters import ProcessedMailFilterSet
|
||||||
from paperless_mail.mail import MailError
|
from paperless_mail.mail import MailError
|
||||||
from paperless_mail.mail import get_mailbox
|
from paperless_mail.mail import get_mailbox
|
||||||
from paperless_mail.mail import mailbox_login
|
from paperless_mail.mail import mailbox_login
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
from paperless_mail.models import MailRule
|
from paperless_mail.models import MailRule
|
||||||
|
from paperless_mail.models import ProcessedMail
|
||||||
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
from paperless_mail.oauth import PaperlessMailOAuth2Manager
|
||||||
from paperless_mail.serialisers import MailAccountSerializer
|
from paperless_mail.serialisers import MailAccountSerializer
|
||||||
from paperless_mail.serialisers import MailRuleSerializer
|
from paperless_mail.serialisers import MailRuleSerializer
|
||||||
|
from paperless_mail.serialisers import ProcessedMailSerializer
|
||||||
from paperless_mail.tasks import process_mail_accounts
|
from paperless_mail.tasks import process_mail_accounts
|
||||||
|
|
||||||
|
|
||||||
@@ -126,6 +134,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
|
|||||||
return Response({"result": "OK"})
|
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):
|
class MailRuleViewSet(ModelViewSet, PassUserMixin):
|
||||||
model = MailRule
|
model = MailRule
|
||||||
|
|
||||||
|
8
uv.lock
generated
8
uv.lock
generated
@@ -782,15 +782,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django-guardian"
|
name = "django-guardian"
|
||||||
version = "3.1.3"
|
version = "3.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
{ name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" },
|
||||||
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux')" },
|
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux')" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/81/d3/436a44c7688fce1a978224c349ba66c95bf9103d548596b7a2694fd58c03/django_guardian-3.1.3.tar.gz", hash = "sha256:12b5e66c18c97088b0adfa033ab14be68c321c170fd3ec438898271f00a71699", size = 93571, upload-time = "2025-09-10T08:36:23.928Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/f9/bcff6a931298b9eb55e1550b55ab964fab747f594ba6d2d81cbe19736c5f/django_guardian-3.2.0.tar.gz", hash = "sha256:9e18ecd2e211b665972690c2d03d27bce0ea4932b5efac24a4bb9d526950a69e", size = 99940, upload-time = "2025-09-16T10:35:53.609Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/fc/6fd7b8bc7c52cbbfd1714673cfd28ff0b3fae32265c52d492ec0dee22cb8/django_guardian-3.1.3-py3-none-any.whl", hash = "sha256:90e28b40eea65c326a3a961908cc300f9e1cd69b74e88d38317a9befa167b71c", size = 127687, upload-time = "2025-09-10T08:36:22.533Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/23/63a7d868373a73d25c4a5c2dd3cce3aaeb22fbee82560d42b6e93ba01403/django_guardian-3.2.0-py3-none-any.whl", hash = "sha256:0768565a057988a93fc4a1d93649c4a794abfd7473a8408a079cfbf83c559d77", size = 134674, upload-time = "2025-09-16T10:35:51.69Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2185,7 +2185,7 @@ requires-dist = [
|
|||||||
{ name = "django-cors-headers", specifier = "~=4.8.0" },
|
{ name = "django-cors-headers", specifier = "~=4.8.0" },
|
||||||
{ name = "django-extensions", specifier = "~=4.1" },
|
{ name = "django-extensions", specifier = "~=4.1" },
|
||||||
{ name = "django-filter", specifier = "~=25.1" },
|
{ name = "django-filter", specifier = "~=25.1" },
|
||||||
{ name = "django-guardian", specifier = "~=3.1.2" },
|
{ name = "django-guardian", specifier = "~=3.2.0" },
|
||||||
{ name = "django-multiselectfield", specifier = "~=1.0.1" },
|
{ name = "django-multiselectfield", specifier = "~=1.0.1" },
|
||||||
{ name = "django-soft-delete", specifier = "~=1.0.18" },
|
{ name = "django-soft-delete", specifier = "~=1.0.18" },
|
||||||
{ name = "django-treenode", specifier = ">=0.23.2" },
|
{ name = "django-treenode", specifier = ">=0.23.2" },
|
||||||
|
Reference in New Issue
Block a user