mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 03:56:23 -05:00 
			
		
		
		
	Compare commits
	
		
			20 Commits
		
	
	
		
			feature-do
			...
			9b05f16a0f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 9b05f16a0f | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | af544177d4 | ||
| ![dependabot[bot]](/assets/img/avatar_default.png)  | 766af6a48a | ||
|   | e985051890 | ||
|   | 764ad059d1 | ||
|   | 5e47069934 | ||
|   | 4ff09c4cf4 | ||
|   | 53b393dab5 | ||
|   | 6119c215e7 | ||
|   | 8d1f23e9d6 | ||
|   | c8850fa752 | ||
|   | 19a54b3b23 | ||
|   | 1cdd8d9ba8 | ||
|   | 4449dbadb5 | ||
|   | 43b4f36026 | ||
|   | 0e35acaef5 | ||
|   | 19ff339804 | ||
|   | 6b868a5ecb | ||
|   | 3e4aa87cc5 | ||
|   | fc95d42b35 | 
							
								
								
									
										53
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										53
									
								
								.github/workflows/ci.yml
									
									
									
									
										vendored
									
									
								
							| @@ -17,11 +17,52 @@ env: | |||||||
|   DEFAULT_PYTHON_VERSION: "3.11" |   DEFAULT_PYTHON_VERSION: "3.11" | ||||||
|   NLTK_DATA: "/usr/share/nltk_data" |   NLTK_DATA: "/usr/share/nltk_data" | ||||||
| jobs: | jobs: | ||||||
|  |   detect-duplicate: | ||||||
|  |     name: Detect Duplicate Run | ||||||
|  |     runs-on: ubuntu-24.04 | ||||||
|  |     outputs: | ||||||
|  |       should_run: ${{ steps.check.outputs.should_run }} | ||||||
|  |     steps: | ||||||
|  |       - name: Check if workflow should run | ||||||
|  |         id: check | ||||||
|  |         uses: actions/github-script@v7 | ||||||
|  |         with: | ||||||
|  |           github-token: ${{ secrets.GITHUB_TOKEN }} | ||||||
|  |           script: | | ||||||
|  |             if (context.eventName !== 'push') { | ||||||
|  |               core.info('Not a push event; running workflow.'); | ||||||
|  |               core.setOutput('should_run', 'true'); | ||||||
|  |               return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             const ref = context.ref || ''; | ||||||
|  |             if (!ref.startsWith('refs/heads/')) { | ||||||
|  |               core.info('Push is not to a branch; running workflow.'); | ||||||
|  |               core.setOutput('should_run', 'true'); | ||||||
|  |               return; | ||||||
|  |             } | ||||||
|  |  | ||||||
|  |             const branch = ref.substring('refs/heads/'.length); | ||||||
|  |             const { owner, repo } = context.repo; | ||||||
|  |             const prs = await github.paginate(github.rest.pulls.list, { | ||||||
|  |               owner, | ||||||
|  |               repo, | ||||||
|  |               state: 'open', | ||||||
|  |               head: `${owner}:${branch}`, | ||||||
|  |               per_page: 100, | ||||||
|  |             }); | ||||||
|  |  | ||||||
|  |             if (prs.length === 0) { | ||||||
|  |               core.info(`No open PR found for ${branch}; running workflow.`); | ||||||
|  |               core.setOutput('should_run', 'true'); | ||||||
|  |             } else { | ||||||
|  |               core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`); | ||||||
|  |               core.setOutput('should_run', 'false'); | ||||||
|  |             } | ||||||
|   pre-commit: |   pre-commit: | ||||||
|     # We want to run on external PRs, but not on our own internal PRs as they'll be run |     needs: | ||||||
|     # by the push to the branch. Without this if check, checks are duplicated since |       - detect-duplicate | ||||||
|     # internal PRs match both the push and pull_request events. |     if: needs.detect-duplicate.outputs.should_run == 'true' | ||||||
|     if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository |  | ||||||
|     name: Linting Checks |     name: Linting Checks | ||||||
|     runs-on: ubuntu-24.04 |     runs-on: ubuntu-24.04 | ||||||
|     steps: |     steps: | ||||||
| @@ -142,13 +183,11 @@ jobs: | |||||||
|         if: always() |         if: always() | ||||||
|         uses: codecov/test-results-action@v1 |         uses: codecov/test-results-action@v1 | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |  | ||||||
|           flags: backend-python-${{ matrix.python-version }} |           flags: backend-python-${{ matrix.python-version }} | ||||||
|           files: junit.xml |           files: junit.xml | ||||||
|       - name: Upload backend coverage to Codecov |       - name: Upload backend coverage to Codecov | ||||||
|         uses: codecov/codecov-action@v5 |         uses: codecov/codecov-action@v5 | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |  | ||||||
|           flags: backend-python-${{ matrix.python-version }} |           flags: backend-python-${{ matrix.python-version }} | ||||||
|           files: coverage.xml |           files: coverage.xml | ||||||
|       - name: Stop containers |       - name: Stop containers | ||||||
| @@ -224,13 +263,11 @@ jobs: | |||||||
|         uses: codecov/test-results-action@v1 |         uses: codecov/test-results-action@v1 | ||||||
|         if: always() |         if: always() | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |  | ||||||
|           flags: frontend-node-${{ matrix.node-version }} |           flags: frontend-node-${{ matrix.node-version }} | ||||||
|           directory: src-ui/ |           directory: src-ui/ | ||||||
|       - name: Upload frontend coverage to Codecov |       - name: Upload frontend coverage to Codecov | ||||||
|         uses: codecov/codecov-action@v5 |         uses: codecov/codecov-action@v5 | ||||||
|         with: |         with: | ||||||
|           token: ${{ secrets.CODECOV_TOKEN }} |  | ||||||
|           flags: frontend-node-${{ matrix.node-version }} |           flags: frontend-node-${{ matrix.node-version }} | ||||||
|           directory: src-ui/coverage/ |           directory: src-ui/coverage/ | ||||||
|   tests-frontend-e2e: |   tests-frontend-e2e: | ||||||
|   | |||||||
							
								
								
									
										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, | ||||||
|   | |||||||
| @@ -2,9 +2,11 @@ | |||||||
|  |  | ||||||
| If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. | If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome. | ||||||
|  |  | ||||||
|  | ⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged. | ||||||
|  |  | ||||||
| If you want to implement something big: | If you want to implement something big: | ||||||
|  |  | ||||||
| - Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together. | - As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together. | ||||||
| - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. | - When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project. | ||||||
| - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. | - Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change. | ||||||
| - Please see the [paperless-ngx merge process](#merging-prs) below. | - Please see the [paperless-ngx merge process](#merging-prs) below. | ||||||
| @@ -133,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and | |||||||
| - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. | - 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. | ||||||
|   | |||||||
| @@ -32,7 +32,7 @@ RUN set -eux \ | |||||||
| # Purpose: Installs s6-overlay and rootfs | # Purpose: Installs s6-overlay and rootfs | ||||||
| # Comments: | # Comments: | ||||||
| #  - Don't leave anything extra in here either | #  - Don't leave anything extra in here either | ||||||
| FROM ghcr.io/astral-sh/uv:0.8.17-python3.12-bookworm-slim AS s6-overlay-base | FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base | ||||||
|  |  | ||||||
| WORKDIR /usr/src/s6 | WORKDIR /usr/src/s6 | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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. | ||||||
|   | |||||||
| @@ -30,7 +30,7 @@ dependencies = [ | |||||||
|   "django-cachalot~=2.8.0", |   "django-cachalot~=2.8.0", | ||||||
|   "django-celery-results~=2.6.0", |   "django-celery-results~=2.6.0", | ||||||
|   "django-compression-middleware~=0.5.0", |   "django-compression-middleware~=0.5.0", | ||||||
|   "django-cors-headers~=4.8.0", |   "django-cors-headers~=4.9.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.1.2", | ||||||
|   | |||||||
| @@ -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"> | ||||||
|   | |||||||
| @@ -177,10 +177,16 @@ export class CustomFieldEditDialogComponent | |||||||
|   } |   } | ||||||
|  |  | ||||||
|   public removeSelectOption(index: number) { |   public removeSelectOption(index: number) { | ||||||
|     this.selectOptions.removeAt(index) |     const globalIndex = | ||||||
|     this._allSelectOptions.splice( |       index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE | ||||||
|       index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE, |     this._allSelectOptions.splice(globalIndex, 1) | ||||||
|       1 |  | ||||||
|  |     const totalPages = Math.max( | ||||||
|  |       1, | ||||||
|  |       Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE) | ||||||
|     ) |     ) | ||||||
|  |     const targetPage = Math.min(this.selectOptionsPage, totalPages) | ||||||
|  |  | ||||||
|  |     this.selectOptionsPage = targetPage | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -164,6 +164,9 @@ class BarcodePlugin(ConsumeTaskPlugin): | |||||||
|                         mailrule_id=self.input_doc.mailrule_id, |                         mailrule_id=self.input_doc.mailrule_id, | ||||||
|                         # Can't use same folder or the consume might grab it again |                         # Can't use same folder or the consume might grab it again | ||||||
|                         original_file=(tmp_dir / new_document.name).resolve(), |                         original_file=(tmp_dir / new_document.name).resolve(), | ||||||
|  |                         # Adding optional original_path for later uses in | ||||||
|  |                         # workflow matching | ||||||
|  |                         original_path=self.input_doc.original_file, | ||||||
|                     ), |                     ), | ||||||
|                     # All the same metadata |                     # All the same metadata | ||||||
|                     self.metadata, |                     self.metadata, | ||||||
|   | |||||||
| @@ -156,6 +156,7 @@ class ConsumableDocument: | |||||||
|  |  | ||||||
|     source: DocumentSource |     source: DocumentSource | ||||||
|     original_file: Path |     original_file: Path | ||||||
|  |     original_path: Path | None = None | ||||||
|     mailrule_id: int | None = None |     mailrule_id: int | None = None | ||||||
|     mime_type: str = dataclasses.field(init=False, default=None) |     mime_type: str = dataclasses.field(init=False, default=None) | ||||||
|  |  | ||||||
|   | |||||||
| @@ -82,6 +82,13 @@ def _is_ignored(filepath: Path) -> bool: | |||||||
|  |  | ||||||
|  |  | ||||||
| def _consume(filepath: Path) -> None: | 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 | ||||||
|  |                         try: | ||||||
|                             file_still_exists = filepath.exists() and filepath.is_file() |                             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) | ||||||
|   | |||||||
| @@ -92,6 +92,9 @@ class Command(MultiProcessMixin, ProgressBarMixin, BaseCommand): | |||||||
|                 # doc to doc is obviously not useful |                 # doc to doc is obviously not useful | ||||||
|                 if first_doc.pk == second_doc.pk: |                 if first_doc.pk == second_doc.pk: | ||||||
|                     continue |                     continue | ||||||
|  |                 # Skip empty documents (e.g. password-protected) | ||||||
|  |                 if first_doc.content.strip() == "" or second_doc.content.strip() == "": | ||||||
|  |                     continue | ||||||
|                 # Skip matching which have already been matched together |                 # Skip matching which have already been matched together | ||||||
|                 # doc 1 to doc 2 is the same as doc 2 to doc 1 |                 # doc 1 to doc 2 is the same as doc 2 to doc 1 | ||||||
|                 doc_1_to_doc_2 = (first_doc.pk, second_doc.pk) |                 doc_1_to_doc_2 = (first_doc.pk, second_doc.pk) | ||||||
|   | |||||||
| @@ -314,11 +314,19 @@ def consumable_document_matches_workflow( | |||||||
|         trigger_matched = False |         trigger_matched = False | ||||||
|  |  | ||||||
|     # Document path vs trigger path |     # Document path vs trigger path | ||||||
|  |  | ||||||
|  |     # Use the original_path if set, else us the original_file | ||||||
|  |     match_against = ( | ||||||
|  |         document.original_path | ||||||
|  |         if document.original_path is not None | ||||||
|  |         else document.original_file | ||||||
|  |     ) | ||||||
|  |  | ||||||
|     if ( |     if ( | ||||||
|         trigger.filter_path is not None |         trigger.filter_path is not None | ||||||
|         and len(trigger.filter_path) > 0 |         and len(trigger.filter_path) > 0 | ||||||
|         and not fnmatch( |         and not fnmatch( | ||||||
|             document.original_file, |             match_against, | ||||||
|             trigger.filter_path, |             trigger.filter_path, | ||||||
|         ) |         ) | ||||||
|     ): |     ): | ||||||
|   | |||||||
| @@ -614,14 +614,16 @@ class TestBarcodeNewConsume( | |||||||
|             self.assertIsNotFile(temp_copy) |             self.assertIsNotFile(temp_copy) | ||||||
|  |  | ||||||
|             # Check the split files exist |             # Check the split files exist | ||||||
|  |             # Check the original_path is set | ||||||
|             # Check the source is unchanged |             # Check the source is unchanged | ||||||
|             # Check the overrides are unchanged |             # Check the overrides are unchanged | ||||||
|             for ( |             for ( | ||||||
|                 new_input_doc, |                 new_input_doc, | ||||||
|                 new_doc_overrides, |                 new_doc_overrides, | ||||||
|             ) in self.get_all_consume_delay_call_args(): |             ) in self.get_all_consume_delay_call_args(): | ||||||
|                 self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) |  | ||||||
|                 self.assertIsFile(new_input_doc.original_file) |                 self.assertIsFile(new_input_doc.original_file) | ||||||
|  |                 self.assertEqual(new_input_doc.original_path, temp_copy) | ||||||
|  |                 self.assertEqual(new_input_doc.source, DocumentSource.ConsumeFolder) | ||||||
|                 self.assertEqual(overrides, new_doc_overrides) |                 self.assertEqual(overrides, new_doc_overrides) | ||||||
|  |  | ||||||
|  |  | ||||||
|   | |||||||
| @@ -209,6 +209,26 @@ class TestConsumer(DirectoriesMixin, ConsumerThreadMixin, TransactionTestCase): | |||||||
|         # assert that we have an error logged with this invalid file. |         # 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") | ||||||
|   | |||||||
| @@ -206,3 +206,29 @@ class TestFuzzyMatchCommand(TestCase): | |||||||
|         self.assertEqual(Document.objects.count(), 2) |         self.assertEqual(Document.objects.count(), 2) | ||||||
|         self.assertIsNotNone(Document.objects.get(pk=1)) |         self.assertIsNotNone(Document.objects.get(pk=1)) | ||||||
|         self.assertIsNotNone(Document.objects.get(pk=2)) |         self.assertIsNotNone(Document.objects.get(pk=2)) | ||||||
|  |  | ||||||
|  |     def test_empty_content(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - 2 documents exist, content is empty (pw-protected) | ||||||
|  |         WHEN: | ||||||
|  |             - Command is called | ||||||
|  |         THEN: | ||||||
|  |             - No matches are found | ||||||
|  |         """ | ||||||
|  |         Document.objects.create( | ||||||
|  |             checksum="BEEFCAFE", | ||||||
|  |             title="A", | ||||||
|  |             content="", | ||||||
|  |             mime_type="application/pdf", | ||||||
|  |             filename="test.pdf", | ||||||
|  |         ) | ||||||
|  |         Document.objects.create( | ||||||
|  |             checksum="DEADBEAF", | ||||||
|  |             title="A", | ||||||
|  |             content="", | ||||||
|  |             mime_type="application/pdf", | ||||||
|  |             filename="other_test.pdf", | ||||||
|  |         ) | ||||||
|  |         stdout, _ = self.call_command() | ||||||
|  |         self.assertIn("No matches found", stdout) | ||||||
|   | |||||||
| @@ -2,7 +2,7 @@ msgid "" | |||||||
| msgstr "" | 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 "" | ||||||
|  |  | ||||||
|   | |||||||
| @@ -922,7 +922,7 @@ CELERY_ACCEPT_CONTENT = ["application/json", "application/x-python-serialize"] | |||||||
| CELERY_BEAT_SCHEDULE = _parse_beat_schedule() | CELERY_BEAT_SCHEDULE = _parse_beat_schedule() | ||||||
|  |  | ||||||
| # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename | # https://docs.celeryq.dev/en/stable/userguide/configuration.html#beat-schedule-filename | ||||||
| CELERY_BEAT_SCHEDULE_FILENAME = DATA_DIR / "celerybeat-schedule.db" | CELERY_BEAT_SCHEDULE_FILENAME = str(DATA_DIR / "celerybeat-schedule.db") | ||||||
|  |  | ||||||
|  |  | ||||||
| # Cachalot: Database read cache. | # Cachalot: Database read cache. | ||||||
|   | |||||||
| @@ -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 | ||||||
|  |  | ||||||
|   | |||||||
							
								
								
									
										42
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										42
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @@ -730,15 +730,15 @@ wheels = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "django-cors-headers" | name = "django-cors-headers" | ||||||
| version = "4.8.0" | version = "4.9.0" | ||||||
| source = { registry = "https://pypi.org/simple" } | source = { registry = "https://pypi.org/simple" } | ||||||
| dependencies = [ | dependencies = [ | ||||||
|     { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, |     { name = "asgiref", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||||
|     { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, |     { name = "django", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, | ||||||
| ] | ] | ||||||
| sdist = { url = "https://files.pythonhosted.org/packages/89/8e/6225441edcfe179bf4861e9e67489e33375e0b66316c8d7b9edaae863d37/django_cors_headers-4.8.0.tar.gz", hash = "sha256:0a12a2efcd59a3cea741e44db8ab589e929949de5bc4cdf35a29c6ae77297686", size = 21425, upload-time = "2025-09-08T15:58:05.34Z" } | sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" } | ||||||
| wheels = [ | wheels = [ | ||||||
|     { url = "https://files.pythonhosted.org/packages/ac/b3/29ef49d6ff7800f323f3d98cde7777b3cfdda133de8feea84cffafea4578/django_cors_headers-4.8.0-py3-none-any.whl", hash = "sha256:3b883f4c6d07848673218456a5e070d8ab51f97341c1f27d0242ca167e7272ab", size = 12804, upload-time = "2025-09-08T15:58:03.882Z" }, |     { url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" }, | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| @@ -2182,7 +2182,7 @@ requires-dist = [ | |||||||
|     { name = "django-cachalot", specifier = "~=2.8.0" }, |     { name = "django-cachalot", specifier = "~=2.8.0" }, | ||||||
|     { name = "django-celery-results", specifier = "~=2.6.0" }, |     { name = "django-celery-results", specifier = "~=2.6.0" }, | ||||||
|     { name = "django-compression-middleware", specifier = "~=0.5.0" }, |     { name = "django-compression-middleware", specifier = "~=0.5.0" }, | ||||||
|     { name = "django-cors-headers", specifier = "~=4.8.0" }, |     { name = "django-cors-headers", specifier = "~=4.9.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.1.2" }, | ||||||
| @@ -3291,25 +3291,25 @@ wheels = [ | |||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
| name = "ruff" | name = "ruff" | ||||||
| version = "0.13.0" | version = "0.13.2" | ||||||
| source = { registry = "https://pypi.org/simple" } | source = { registry = "https://pypi.org/simple" } | ||||||
| sdist = { url = "https://files.pythonhosted.org/packages/6e/1a/1f4b722862840295bcaba8c9e5261572347509548faaa99b2d57ee7bfe6a/ruff-0.13.0.tar.gz", hash = "sha256:5b4b1ee7eb35afae128ab94459b13b2baaed282b1fb0f472a73c82c996c8ae60", size = 5372863, upload-time = "2025-09-10T16:25:37.917Z" } | sdist = { url = "https://files.pythonhosted.org/packages/02/df/8d7d8c515d33adfc540e2edf6c6021ea1c5a58a678d8cfce9fae59aabcab/ruff-0.13.2.tar.gz", hash = "sha256:cb12fffd32fb16d32cef4ed16d8c7cdc27ed7c944eaa98d99d01ab7ab0b710ff", size = 5416417, upload-time = "2025-09-25T14:54:09.936Z" } | ||||||
| wheels = [ | wheels = [ | ||||||
|     { url = "https://files.pythonhosted.org/packages/ac/fe/6f87b419dbe166fd30a991390221f14c5b68946f389ea07913e1719741e0/ruff-0.13.0-py3-none-linux_armv6l.whl", hash = "sha256:137f3d65d58ee828ae136a12d1dc33d992773d8f7644bc6b82714570f31b2004", size = 12187826, upload-time = "2025-09-10T16:24:39.5Z" }, |     { url = "https://files.pythonhosted.org/packages/6e/84/5716a7fa4758e41bf70e603e13637c42cfb9dbf7ceb07180211b9bbf75ef/ruff-0.13.2-py3-none-linux_armv6l.whl", hash = "sha256:3796345842b55f033a78285e4f1641078f902020d8450cade03aad01bffd81c3", size = 12343254, upload-time = "2025-09-25T14:53:27.784Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/e4/25/c92296b1fc36d2499e12b74a3fdb230f77af7bdf048fad7b0a62e94ed56a/ruff-0.13.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:21ae48151b66e71fd111b7d79f9ad358814ed58c339631450c66a4be33cc28b9", size = 12933428, upload-time = "2025-09-10T16:24:43.866Z" }, |     { url = "https://files.pythonhosted.org/packages/9b/77/c7042582401bb9ac8eff25360e9335e901d7a1c0749a2b28ba4ecb239991/ruff-0.13.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ff7e4dda12e683e9709ac89e2dd436abf31a4d8a8fc3d89656231ed808e231d2", size = 13040891, upload-time = "2025-09-25T14:53:31.38Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/44/cf/40bc7221a949470307d9c35b4ef5810c294e6cfa3caafb57d882731a9f42/ruff-0.13.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:64de45f4ca5441209e41742d527944635a05a6e7c05798904f39c85bafa819e3", size = 12095543, upload-time = "2025-09-10T16:24:46.638Z" }, |     { url = "https://files.pythonhosted.org/packages/c6/15/125a7f76eb295cb34d19c6778e3a82ace33730ad4e6f28d3427e134a02e0/ruff-0.13.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c75e9d2a2fafd1fdd895d0e7e24b44355984affdde1c412a6f6d3f6e16b22d46", size = 12243588, upload-time = "2025-09-25T14:53:33.543Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/f1/03/8b5ff2a211efb68c63a1d03d157e924997ada87d01bebffbd13a0f3fcdeb/ruff-0.13.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2c653ae9b9d46e0ef62fc6fbf5b979bda20a0b1d2b22f8f7eb0cde9f4963b8", size = 12312489, upload-time = "2025-09-10T16:24:49.556Z" }, |     { url = "https://files.pythonhosted.org/packages/9e/eb/0093ae04a70f81f8be7fd7ed6456e926b65d238fc122311293d033fdf91e/ruff-0.13.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cceac74e7bbc53ed7d15d1042ffe7b6577bf294611ad90393bf9b2a0f0ec7cb6", size = 12491359, upload-time = "2025-09-25T14:53:35.892Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/37/fc/2336ef6d5e9c8d8ea8305c5f91e767d795cd4fc171a6d97ef38a5302dadc/ruff-0.13.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4cec632534332062bc9eb5884a267b689085a1afea9801bf94e3ba7498a2d207", size = 11991631, upload-time = "2025-09-10T16:24:53.439Z" }, |     { url = "https://files.pythonhosted.org/packages/43/fe/72b525948a6956f07dad4a6f122336b6a05f2e3fd27471cea612349fedb9/ruff-0.13.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6ae3f469b5465ba6d9721383ae9d49310c19b452a161b57507764d7ef15f4b07", size = 12162486, upload-time = "2025-09-25T14:53:38.171Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/39/7f/f6d574d100fca83d32637d7f5541bea2f5e473c40020bbc7fc4a4d5b7294/ruff-0.13.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dcd628101d9f7d122e120ac7c17e0a0f468b19bc925501dbe03c1cb7f5415b24", size = 13720602, upload-time = "2025-09-10T16:24:56.392Z" }, |     { url = "https://files.pythonhosted.org/packages/6a/e3/0fac422bbbfb2ea838023e0d9fcf1f30183d83ab2482800e2cb892d02dfe/ruff-0.13.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f8f9e3cd6714358238cd6626b9d43026ed19c0c018376ac1ef3c3a04ffb42d8", size = 13871203, upload-time = "2025-09-25T14:53:41.943Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/fd/c8/a8a5b81d8729b5d1f663348d11e2a9d65a7a9bd3c399763b1a51c72be1ce/ruff-0.13.0-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afe37db8e1466acb173bb2a39ca92df00570e0fd7c94c72d87b51b21bb63efea", size = 14697751, upload-time = "2025-09-10T16:24:59.89Z" }, |     { url = "https://files.pythonhosted.org/packages/6b/82/b721c8e3ec5df6d83ba0e45dcf00892c4f98b325256c42c38ef136496cbf/ruff-0.13.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c6ed79584a8f6cbe2e5d7dbacf7cc1ee29cbdb5df1172e77fbdadc8bb85a1f89", size = 14929635, upload-time = "2025-09-25T14:53:43.953Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/57/f5/183ec292272ce7ec5e882aea74937f7288e88ecb500198b832c24debc6d3/ruff-0.13.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f96a8d90bb258d7d3358b372905fe7333aaacf6c39e2408b9f8ba181f4b6ef2", size = 14095317, upload-time = "2025-09-10T16:25:03.025Z" }, |     { url = "https://files.pythonhosted.org/packages/c4/a0/ad56faf6daa507b83079a1ad7a11694b87d61e6bf01c66bd82b466f21821/ruff-0.13.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aed130b2fde049cea2019f55deb939103123cdd191105f97a0599a3e753d61b0", size = 14338783, upload-time = "2025-09-25T14:53:46.205Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/9f/8d/7f9771c971724701af7926c14dab31754e7b303d127b0d3f01116faef456/ruff-0.13.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b5e3d883e4f924c5298e3f2ee0f3085819c14f68d1e5b6715597681433f153", size = 13144418, upload-time = "2025-09-10T16:25:06.272Z" }, |     { url = "https://files.pythonhosted.org/packages/47/77/ad1d9156db8f99cd01ee7e29d74b34050e8075a8438e589121fcd25c4b08/ruff-0.13.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1887c230c2c9d65ed1b4e4cfe4d255577ea28b718ae226c348ae68df958191aa", size = 13355322, upload-time = "2025-09-25T14:53:48.164Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/a8/a6/7985ad1778e60922d4bef546688cd8a25822c58873e9ff30189cfe5dc4ab/ruff-0.13.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03447f3d18479df3d24917a92d768a89f873a7181a064858ea90a804a7538991", size = 13370843, upload-time = "2025-09-10T16:25:09.965Z" }, |     { url = "https://files.pythonhosted.org/packages/64/8b/e87cfca2be6f8b9f41f0bb12dc48c6455e2d66df46fe61bb441a226f1089/ruff-0.13.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5bcb10276b69b3cfea3a102ca119ffe5c6ba3901e20e60cf9efb53fa417633c3", size = 13354427, upload-time = "2025-09-25T14:53:50.486Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/64/1c/bafdd5a7a05a50cc51d9f5711da704942d8dd62df3d8c70c311e98ce9f8a/ruff-0.13.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:fbc6b1934eb1c0033da427c805e27d164bb713f8e273a024a7e86176d7f462cf", size = 13321891, upload-time = "2025-09-10T16:25:12.969Z" }, |     { url = "https://files.pythonhosted.org/packages/7f/df/bf382f3fbead082a575edb860897287f42b1b3c694bafa16bc9904c11ed3/ruff-0.13.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:afa721017aa55a555b2ff7944816587f1cb813c2c0a882d158f59b832da1660d", size = 13537637, upload-time = "2025-09-25T14:53:52.887Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/bc/3e/7817f989cb9725ef7e8d2cee74186bf90555279e119de50c750c4b7a72fe/ruff-0.13.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a8ab6a3e03665d39d4a25ee199d207a488724f022db0e1fe4002968abdb8001b", size = 12119119, upload-time = "2025-09-10T16:25:16.621Z" }, |     { url = "https://files.pythonhosted.org/packages/51/70/1fb7a7c8a6fc8bd15636288a46e209e81913b87988f26e1913d0851e54f4/ruff-0.13.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1dbc875cf3720c64b3990fef8939334e74cb0ca65b8dbc61d1f439201a38101b", size = 12340025, upload-time = "2025-09-25T14:53:54.88Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/58/07/9df080742e8d1080e60c426dce6e96a8faf9a371e2ce22eef662e3839c95/ruff-0.13.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d2a5c62f8ccc6dd2fe259917482de7275cecc86141ee10432727c4816235bc41", size = 11961594, upload-time = "2025-09-10T16:25:19.49Z" }, |     { url = "https://files.pythonhosted.org/packages/4c/27/1e5b3f1c23ca5dd4106d9d580e5c13d9acb70288bff614b3d7b638378cc9/ruff-0.13.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5b939a1b2a960e9742e9a347e5bbc9b3c3d2c716f86c6ae273d9cbd64f193f22", size = 12133449, upload-time = "2025-09-25T14:53:57.089Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/6a/f4/ae1185349197d26a2316840cb4d6c3fba61d4ac36ed728bf0228b222d71f/ruff-0.13.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b7b85ca27aeeb1ab421bc787009831cffe6048faae08ad80867edab9f2760945", size = 12933377, upload-time = "2025-09-10T16:25:22.371Z" }, |     { url = "https://files.pythonhosted.org/packages/2d/09/b92a5ccee289f11ab128df57d5911224197d8d55ef3bd2043534ff72ca54/ruff-0.13.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:50e2d52acb8de3804fc5f6e2fa3ae9bdc6812410a9e46837e673ad1f90a18736", size = 13051369, upload-time = "2025-09-25T14:53:59.124Z" }, | ||||||
|     { url = "https://files.pythonhosted.org/packages/b6/39/e776c10a3b349fc8209a905bfb327831d7516f6058339a613a8d2aaecacd/ruff-0.13.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:79ea0c44a3032af768cabfd9616e44c24303af49d633b43e3a5096e009ebe823", size = 13418555, upload-time = "2025-09-10T16:25:25.681Z" }, |     { url = "https://files.pythonhosted.org/packages/89/99/26c9d1c7d8150f45e346dc045cc49f23e961efceb4a70c47dea0960dea9a/ruff-0.13.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3196bc13ab2110c176b9a4ae5ff7ab676faaa1964b330a1383ba20e1e19645f2", size = 13523644, upload-time = "2025-09-25T14:54:01.622Z" }, | ||||||
| ] | ] | ||||||
|  |  | ||||||
| [[package]] | [[package]] | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user