mirror of
				https://github.com/paperless-ngx/paperless-ngx.git
				synced 2025-10-30 10:12:35 -04:00 
			
		
		
		
	Merge branch 'dev' into feature-ai
This commit is contained in:
		
						commit
						bc48b4025c
					
				
							
								
								
									
										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, | ||||||
|  | |||||||
| @ -135,7 +135,7 @@ community members. That said, in an effort to keep the repository organized and | |||||||
| - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. | - Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity. | ||||||
| - Discussions with a marked answer will be automatically closed. | - Discussions with a marked answer will be automatically closed. | ||||||
| - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. | - Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity. | ||||||
| - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years. | - Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years. | ||||||
| 
 | 
 | ||||||
| In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. | In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns. | ||||||
| Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. | Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features. | ||||||
|  | |||||||
| @ -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 | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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", | ||||||
|  | |||||||
| @ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => { | |||||||
|   await expect(page.locator('pngx-document-list')).toHaveText( |   await expect(page.locator('pngx-document-list')).toHaveText( | ||||||
|     /Selected 61 of 61 documents/i |     /Selected 61 of 61 documents/i | ||||||
|   ) |   ) | ||||||
|   await page.getByRole('button', { name: 'Cancel' }).click() |   await page.getByRole('button', { name: 'None' }).click() | ||||||
| 
 | 
 | ||||||
|   await page.locator('pngx-document-card-small').nth(1).click() |   await page.locator('pngx-document-card-small').nth(1).click() | ||||||
|   await page.locator('pngx-document-card-small').nth(2).click() |   await page.locator('pngx-document-card-small').nth(2).click() | ||||||
|  | |||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @ -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 | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
| @ -1,161 +1,144 @@ | |||||||
| <div class="d-flex flex-wrap gap-4"> | <div class="d-flex flex-wrap gap-4"> | ||||||
|   <div class="d-flex align-items-center" role="group" aria-label="Select"> |   <div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> | ||||||
|     <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> |     <label class="me-2" i18n>Edit:</label> | ||||||
|       <i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container> |     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { | ||||||
|  |       <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title | ||||||
|  |         filterPlaceholder="Filter tags" i18n-filterPlaceholder | ||||||
|  |         [disabled]="!userCanEditAll || disabled" | ||||||
|  |         [editing]="true" | ||||||
|  |         [applyOnClose]="applyOnClose" | ||||||
|  |         [createRef]="createTag.bind(this)" | ||||||
|  |         (opened)="openTagsDropdown()" | ||||||
|  |         [(selectionModel)]="tagSelectionModel" | ||||||
|  |         [documentCounts]="tagDocumentCounts" | ||||||
|  |         (apply)="setTags($event)" | ||||||
|  |         shortcutKey="t"> | ||||||
|  |       </pngx-filterable-dropdown> | ||||||
|  |     } | ||||||
|  |     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { | ||||||
|  |       <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title | ||||||
|  |         filterPlaceholder="Filter correspondents" i18n-filterPlaceholder | ||||||
|  |         [disabled]="!userCanEditAll || disabled" | ||||||
|  |         [editing]="true" | ||||||
|  |         [applyOnClose]="applyOnClose" | ||||||
|  |         [createRef]="createCorrespondent.bind(this)" | ||||||
|  |         (opened)="openCorrespondentDropdown()" | ||||||
|  |         [(selectionModel)]="correspondentSelectionModel" | ||||||
|  |         [documentCounts]="correspondentDocumentCounts" | ||||||
|  |         (apply)="setCorrespondents($event)" | ||||||
|  |         shortcutKey="y"> | ||||||
|  |       </pngx-filterable-dropdown> | ||||||
|  |     } | ||||||
|  |     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { | ||||||
|  |       <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title | ||||||
|  |         filterPlaceholder="Filter document types" i18n-filterPlaceholder | ||||||
|  |         [disabled]="!userCanEditAll || disabled" | ||||||
|  |         [editing]="true" | ||||||
|  |         [applyOnClose]="applyOnClose" | ||||||
|  |         [createRef]="createDocumentType.bind(this)" | ||||||
|  |         (opened)="openDocumentTypeDropdown()" | ||||||
|  |         [(selectionModel)]="documentTypeSelectionModel" | ||||||
|  |         [documentCounts]="documentTypeDocumentCounts" | ||||||
|  |         (apply)="setDocumentTypes($event)" | ||||||
|  |         shortcutKey="u"> | ||||||
|  |       </pngx-filterable-dropdown> | ||||||
|  |     } | ||||||
|  |     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { | ||||||
|  |       <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title | ||||||
|  |         filterPlaceholder="Filter storage paths" i18n-filterPlaceholder | ||||||
|  |         [disabled]="!userCanEditAll || disabled" | ||||||
|  |         [editing]="true" | ||||||
|  |         [applyOnClose]="applyOnClose" | ||||||
|  |         [createRef]="createStoragePath.bind(this)" | ||||||
|  |         (opened)="openStoragePathDropdown()" | ||||||
|  |         [(selectionModel)]="storagePathsSelectionModel" | ||||||
|  |         [documentCounts]="storagePathDocumentCounts" | ||||||
|  |         (apply)="setStoragePaths($event)" | ||||||
|  |         shortcutKey="i"> | ||||||
|  |       </pngx-filterable-dropdown> | ||||||
|  |     } | ||||||
|  |     @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { | ||||||
|  |       <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title | ||||||
|  |         filterPlaceholder="Filter custom fields" i18n-filterPlaceholder | ||||||
|  |         [disabled]="!userCanEditAll" | ||||||
|  |         [editing]="true" | ||||||
|  |         [applyOnClose]="applyOnClose" | ||||||
|  |         [createRef]="createCustomField.bind(this)" | ||||||
|  |         (opened)="openCustomFieldsDropdown()" | ||||||
|  |         [(selectionModel)]="customFieldsSelectionModel" | ||||||
|  |         [documentCounts]="customFieldDocumentCounts" | ||||||
|  |         extraButtonTitle="Set values" | ||||||
|  |         i18n-extraButtonTitle | ||||||
|  |         (extraButton)="setCustomFieldValues($event)" | ||||||
|  |         (apply)="setCustomFields($event)"> | ||||||
|  |       </pngx-filterable-dropdown> | ||||||
|  |     } | ||||||
|  |     <div class="btn-group"> | ||||||
|  |       <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> | ||||||
|  |         <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> | ||||||
|       </button> |       </button> | ||||||
|     </div> |     </div> | ||||||
|     <div class="d-flex align-items-center gap-2" role="group" aria-label="Select"> |   </div> | ||||||
|       <label class="me-2" i18n>Select:</label> |   <div class="d-flex align-items-center gap-2 ms-auto"> | ||||||
|       <div class="btn-group"> |     <div class="btn-toolbar"> | ||||||
|         <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> |       <div ngbDropdown> | ||||||
|           <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> |         <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> | ||||||
|  |           <i-bs name="three-dots"></i-bs> | ||||||
|  |           <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> | ||||||
|  |         </button> | ||||||
|  |         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||||
|  |           <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> | ||||||
|  |             <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> | ||||||
|  |           </button> | ||||||
|  |           <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> | ||||||
|  |             <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> | ||||||
|  |           </button> | ||||||
|  |           <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> | ||||||
|  |             <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> | ||||||
|           </button> |           </button> | ||||||
|           <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> |  | ||||||
|             <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> |  | ||||||
|             </button> |  | ||||||
|           </div> |  | ||||||
|         </div> |         </div> | ||||||
|         <div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> |       </div> | ||||||
|           <label class="me-2" i18n>Edit:</label> |     </div> | ||||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { |     <div class="btn-group btn-group-sm"> | ||||||
|             <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title |       <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> | ||||||
|               filterPlaceholder="Filter tags" i18n-filterPlaceholder |         @if (!awaitingDownload) { | ||||||
|               [disabled]="!userCanEditAll || disabled" |           <i-bs name="arrow-down"></i-bs> | ||||||
|               [editing]="true" |         } | ||||||
|               [applyOnClose]="applyOnClose" |         @if (awaitingDownload) { | ||||||
|               [createRef]="createTag.bind(this)" |           <div class="spinner-border spinner-border-sm" role="status"> | ||||||
|               (opened)="openTagsDropdown()" |             <span class="visually-hidden">Preparing download...</span> | ||||||
|               [(selectionModel)]="tagSelectionModel" |           </div> | ||||||
|               [documentCounts]="tagDocumentCounts" |         } | ||||||
|               (apply)="setTags($event)" |         <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> | ||||||
|               shortcutKey="t"> |       </button> | ||||||
|             </pngx-filterable-dropdown> |       <div ngbDropdown class="me-2 d-flex btn-group" role="group"> | ||||||
|           } |         <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> | ||||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { |         <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> | ||||||
|             <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title |           <form [formGroup]="downloadForm" class="px-3 py-1"> | ||||||
|               filterPlaceholder="Filter correspondents" i18n-filterPlaceholder |             <p class="mb-1" i18n>Include:</p> | ||||||
|               [disabled]="!userCanEditAll || disabled" |             <div class="form-group ps-3 mb-2"> | ||||||
|               [editing]="true" |               <div class="form-check"> | ||||||
|               [applyOnClose]="applyOnClose" |                 <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> | ||||||
|               [createRef]="createCorrespondent.bind(this)" |                 <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> | ||||||
|               (opened)="openCorrespondentDropdown()" |               </div> | ||||||
|               [(selectionModel)]="correspondentSelectionModel" |               <div class="form-check"> | ||||||
|               [documentCounts]="correspondentDocumentCounts" |                 <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> | ||||||
|               (apply)="setCorrespondents($event)" |                 <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> | ||||||
|               shortcutKey="y"> |               </div> | ||||||
|             </pngx-filterable-dropdown> |             </div> | ||||||
|           } |             <div class="form-check"> | ||||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { |               <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> | ||||||
|             <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title |               <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> | ||||||
|               filterPlaceholder="Filter document types" i18n-filterPlaceholder |             </div> | ||||||
|               [disabled]="!userCanEditAll || disabled" |           </form> | ||||||
|               [editing]="true" |  | ||||||
|               [applyOnClose]="applyOnClose" |  | ||||||
|               [createRef]="createDocumentType.bind(this)" |  | ||||||
|               (opened)="openDocumentTypeDropdown()" |  | ||||||
|               [(selectionModel)]="documentTypeSelectionModel" |  | ||||||
|               [documentCounts]="documentTypeDocumentCounts" |  | ||||||
|               (apply)="setDocumentTypes($event)" |  | ||||||
|               shortcutKey="u"> |  | ||||||
|             </pngx-filterable-dropdown> |  | ||||||
|           } |  | ||||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { |  | ||||||
|             <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title |  | ||||||
|               filterPlaceholder="Filter storage paths" i18n-filterPlaceholder |  | ||||||
|               [disabled]="!userCanEditAll || disabled" |  | ||||||
|               [editing]="true" |  | ||||||
|               [applyOnClose]="applyOnClose" |  | ||||||
|               [createRef]="createStoragePath.bind(this)" |  | ||||||
|               (opened)="openStoragePathDropdown()" |  | ||||||
|               [(selectionModel)]="storagePathsSelectionModel" |  | ||||||
|               [documentCounts]="storagePathDocumentCounts" |  | ||||||
|               (apply)="setStoragePaths($event)" |  | ||||||
|               shortcutKey="i"> |  | ||||||
|             </pngx-filterable-dropdown> |  | ||||||
|           } |  | ||||||
|           @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { |  | ||||||
|             <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title |  | ||||||
|               filterPlaceholder="Filter custom fields" i18n-filterPlaceholder |  | ||||||
|               [disabled]="!userCanEditAll" |  | ||||||
|               [editing]="true" |  | ||||||
|               [applyOnClose]="applyOnClose" |  | ||||||
|               [createRef]="createCustomField.bind(this)" |  | ||||||
|               (opened)="openCustomFieldsDropdown()" |  | ||||||
|               [(selectionModel)]="customFieldsSelectionModel" |  | ||||||
|               [documentCounts]="customFieldDocumentCounts" |  | ||||||
|               extraButtonTitle="Set values" |  | ||||||
|               i18n-extraButtonTitle |  | ||||||
|               (extraButton)="setCustomFieldValues($event)" |  | ||||||
|               (apply)="setCustomFields($event)"> |  | ||||||
|             </pngx-filterable-dropdown> |  | ||||||
|           } |  | ||||||
|         </div> |         </div> | ||||||
|         <div class="d-flex align-items-center gap-2 ms-auto"> |       </div> | ||||||
|           <div class="btn-toolbar"> |     </div> | ||||||
| 
 | 
 | ||||||
|             <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> |     <div class="btn-group btn-group-sm"> | ||||||
|               <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div> |       <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> | ||||||
|             </button> |         <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> | ||||||
| 
 |       </button> | ||||||
|             <div ngbDropdown> |     </div> | ||||||
|               <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> |   </div> | ||||||
|                 <i-bs name="three-dots"></i-bs> | </div> | ||||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div> |  | ||||||
|               </button> |  | ||||||
|               <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> |  | ||||||
|                 <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> |  | ||||||
|                   <i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container> |  | ||||||
|                 </button> |  | ||||||
|                 <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> |  | ||||||
|                   <i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container> |  | ||||||
|                 </button> |  | ||||||
|                 <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> |  | ||||||
|                   <i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container> |  | ||||||
|                 </button> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
| 
 |  | ||||||
|             <div class="btn-group btn-group-sm"> |  | ||||||
|               <button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()"> |  | ||||||
|                 @if (!awaitingDownload) { |  | ||||||
|                   <i-bs name="arrow-down"></i-bs> |  | ||||||
|                 } |  | ||||||
|                 @if (awaitingDownload) { |  | ||||||
|                   <div class="spinner-border spinner-border-sm" role="status"> |  | ||||||
|                     <span class="visually-hidden">Preparing download...</span> |  | ||||||
|                   </div> |  | ||||||
|                 } |  | ||||||
|                 <div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div> |  | ||||||
|               </button> |  | ||||||
|               <div ngbDropdown class="me-2 d-flex btn-group" role="group"> |  | ||||||
|                 <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> |  | ||||||
|                 <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> |  | ||||||
|                   <form [formGroup]="downloadForm" class="px-3 py-1"> |  | ||||||
|                     <p class="mb-1" i18n>Include:</p> |  | ||||||
|                     <div class="form-group ps-3 mb-2"> |  | ||||||
|                       <div class="form-check"> |  | ||||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> |  | ||||||
|                         <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label> |  | ||||||
|                       </div> |  | ||||||
|                       <div class="form-check"> |  | ||||||
|                         <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> |  | ||||||
|                         <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label> |  | ||||||
|                       </div> |  | ||||||
|                     </div> |  | ||||||
|                     <div class="form-check"> |  | ||||||
|                       <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> |  | ||||||
|                       <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label> |  | ||||||
|                     </div> |  | ||||||
|                   </form> |  | ||||||
|                 </div> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
| 
 |  | ||||||
|             <div class="btn-group btn-group-sm"> |  | ||||||
|               <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> |  | ||||||
|                 <i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container> |  | ||||||
|                 </button> |  | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |  | ||||||
|  | |||||||
| @ -5,3 +5,7 @@ | |||||||
| .dropdown-menu{ | .dropdown-menu{ | ||||||
|     --bs-dropdown-min-width: 12rem; |     --bs-dropdown-min-width: 12rem; | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | .btn-group .btn { | ||||||
|  |   white-space: nowrap; | ||||||
|  | } | ||||||
|  | |||||||
| @ -1,16 +1,36 @@ | |||||||
| <pngx-page-header [title]="getTitle()"> | <pngx-page-header [title]="getTitle()"> | ||||||
| 
 |   <div ngbDropdown class="btn-group flex-fill d-sm-none"> | ||||||
|   <div ngbDropdown class="btn-group flex-fill"> |     <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> | ||||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle> |  | ||||||
|       <i-bs name="text-indent-left"></i-bs> |       <i-bs name="text-indent-left"></i-bs> | ||||||
|       <div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div> |       <div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div> | ||||||
|  |       @if (list.selected.size > 0) { | ||||||
|  |         <pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> | ||||||
|  |       } | ||||||
|     </button> |     </button> | ||||||
|     <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> |     <div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow"> | ||||||
|       <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button> |       <button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button> | ||||||
|       <button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button> |       <button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button> | ||||||
|       <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> |       <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> | ||||||
|     </div> |     </div> | ||||||
|   </div> |   </div> | ||||||
|  |   <div class="d-none d-sm-flex flex-fill me-3"> | ||||||
|  |     <div class="input-group input-group-sm"> | ||||||
|  |       <span class="input-group-text border-0">Select:</span> | ||||||
|  |     </div> | ||||||
|  |     <div class="btn-group btn-group-sm flex-nowrap"> | ||||||
|  |       @if (list.selected.size > 0) { | ||||||
|  |         <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> | ||||||
|  |           <i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container> | ||||||
|  |         </button> | ||||||
|  |       } | ||||||
|  |       <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> | ||||||
|  |         <i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container> | ||||||
|  |       </button> | ||||||
|  |       <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> | ||||||
|  |         <i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|   <div ngbDropdown class="btn-group flex-fill"> |   <div ngbDropdown class="btn-group flex-fill"> | ||||||
|     <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> |     <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> | ||||||
|       <i-bs name="card-heading"></i-bs> |       <i-bs name="card-heading"></i-bs> | ||||||
| @ -126,8 +146,13 @@ | |||||||
|       @if (!list.isReloading && isFiltered) { |       @if (!list.isReloading && isFiltered) { | ||||||
|         <button class="btn btn-link py-0" (click)="resetFilters()"> |         <button class="btn btn-link py-0" (click)="resetFilters()"> | ||||||
|           <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small> |           <i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small> | ||||||
|           </button> |         </button> | ||||||
|         } |       } | ||||||
|  |       @if (!list.isReloading && list.selected.size > 0) { | ||||||
|  |         <button class="btn btn-link py-0" (click)="list.selectNone()"> | ||||||
|  |           <i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small> | ||||||
|  |         </button> | ||||||
|  |       } | ||||||
|       </div> |       </div> | ||||||
|       @if (list.collectionSize) { |       @if (list.collectionSize) { | ||||||
|         <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" |         <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" | ||||||
|  | |||||||
| @ -56,6 +56,7 @@ import { | |||||||
|   filterRulesDiffer, |   filterRulesDiffer, | ||||||
|   isFullTextFilterRule, |   isFullTextFilterRule, | ||||||
| } from 'src/app/utils/filter-rules' | } from 'src/app/utils/filter-rules' | ||||||
|  | import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component' | ||||||
| import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component' | import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component' | ||||||
| import { PageHeaderComponent } from '../common/page-header/page-header.component' | import { PageHeaderComponent } from '../common/page-header/page-header.component' | ||||||
| import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component' | import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component' | ||||||
| @ -72,6 +73,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi | |||||||
|   templateUrl: './document-list.component.html', |   templateUrl: './document-list.component.html', | ||||||
|   styleUrls: ['./document-list.component.scss'], |   styleUrls: ['./document-list.component.scss'], | ||||||
|   imports: [ |   imports: [ | ||||||
|  |     ClearableBadgeComponent, | ||||||
|     CustomFieldDisplayComponent, |     CustomFieldDisplayComponent, | ||||||
|     PageHeaderComponent, |     PageHeaderComponent, | ||||||
|     BulkEditorComponent, |     BulkEditorComponent, | ||||||
|  | |||||||
| @ -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) | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										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, | ||||||
|  |     }) | ||||||
|  |   } | ||||||
|  | } | ||||||
| @ -53,6 +53,7 @@ import { | |||||||
|   check, |   check, | ||||||
|   check2All, |   check2All, | ||||||
|   checkAll, |   checkAll, | ||||||
|  |   checkCircle, | ||||||
|   checkCircleFill, |   checkCircleFill, | ||||||
|   checkLg, |   checkLg, | ||||||
|   chevronDoubleLeft, |   chevronDoubleLeft, | ||||||
| @ -62,6 +63,7 @@ import { | |||||||
|   clipboardCheck, |   clipboardCheck, | ||||||
|   clipboardCheckFill, |   clipboardCheckFill, | ||||||
|   clipboardFill, |   clipboardFill, | ||||||
|  |   clockHistory, | ||||||
|   dash, |   dash, | ||||||
|   dashCircle, |   dashCircle, | ||||||
|   diagram3, |   diagram3, | ||||||
| @ -267,6 +269,7 @@ const icons = { | |||||||
|   check, |   check, | ||||||
|   check2All, |   check2All, | ||||||
|   checkAll, |   checkAll, | ||||||
|  |   checkCircle, | ||||||
|   checkCircleFill, |   checkCircleFill, | ||||||
|   checkLg, |   checkLg, | ||||||
|   chevronDoubleLeft, |   chevronDoubleLeft, | ||||||
| @ -276,6 +279,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) | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -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 "" | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -58,6 +58,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) | ||||||
| @ -78,6 +79,7 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet) | |||||||
| api_router.register(r"workflows", WorkflowViewSet) | api_router.register(r"workflows", WorkflowViewSet) | ||||||
| api_router.register(r"custom_fields", CustomFieldViewSet) | api_router.register(r"custom_fields", CustomFieldViewSet) | ||||||
| api_router.register(r"config", ApplicationConfigurationViewSet) | api_router.register(r"config", ApplicationConfigurationViewSet) | ||||||
|  | api_router.register(r"processed_mail", ProcessedMailViewSet) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| urlpatterns = [ | urlpatterns = [ | ||||||
|  | |||||||
							
								
								
									
										12
									
								
								src/paperless_mail/filters.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								src/paperless_mail/filters.py
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,12 @@ | |||||||
|  | from django_filters import FilterSet | ||||||
|  | 
 | ||||||
|  | from paperless_mail.models import ProcessedMail | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ProcessedMailFilterSet(FilterSet): | ||||||
|  |     class Meta: | ||||||
|  |         model = ProcessedMail | ||||||
|  |         fields = { | ||||||
|  |             "rule": ["exact"], | ||||||
|  |             "status": ["exact"], | ||||||
|  |         } | ||||||
| @ -6,6 +6,7 @@ from documents.serialisers import OwnedObjectSerializer | |||||||
| from documents.serialisers import TagsField | from documents.serialisers import TagsField | ||||||
| from paperless_mail.models import MailAccount | from paperless_mail.models import MailAccount | ||||||
| from paperless_mail.models import MailRule | from paperless_mail.models import MailRule | ||||||
|  | from paperless_mail.models import ProcessedMail | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| class ObfuscatedPasswordField(serializers.CharField): | class ObfuscatedPasswordField(serializers.CharField): | ||||||
| @ -130,3 +131,20 @@ class MailRuleSerializer(OwnedObjectSerializer): | |||||||
|         if value > 36500:  # ~100 years |         if value > 36500:  # ~100 years | ||||||
|             raise serializers.ValidationError("Maximum mail age is unreasonably large.") |             raise serializers.ValidationError("Maximum mail age is unreasonably large.") | ||||||
|         return value |         return value | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class ProcessedMailSerializer(OwnedObjectSerializer): | ||||||
|  |     class Meta: | ||||||
|  |         model = ProcessedMail | ||||||
|  |         fields = [ | ||||||
|  |             "id", | ||||||
|  |             "owner", | ||||||
|  |             "rule", | ||||||
|  |             "folder", | ||||||
|  |             "uid", | ||||||
|  |             "subject", | ||||||
|  |             "received", | ||||||
|  |             "processed", | ||||||
|  |             "status", | ||||||
|  |             "error", | ||||||
|  |         ] | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ from unittest import mock | |||||||
| 
 | 
 | ||||||
| from django.contrib.auth.models import Permission | from django.contrib.auth.models import Permission | ||||||
| from django.contrib.auth.models import User | from django.contrib.auth.models import User | ||||||
|  | from django.utils import timezone | ||||||
| from guardian.shortcuts import assign_perm | from guardian.shortcuts import assign_perm | ||||||
| from rest_framework import status | from rest_framework import status | ||||||
| from rest_framework.test import APITestCase | from rest_framework.test import APITestCase | ||||||
| @ -13,6 +14,7 @@ from documents.models import Tag | |||||||
| from documents.tests.utils import DirectoriesMixin | from documents.tests.utils import DirectoriesMixin | ||||||
| from paperless_mail.models import MailAccount | from paperless_mail.models import MailAccount | ||||||
| from paperless_mail.models import MailRule | from paperless_mail.models import MailRule | ||||||
|  | from paperless_mail.models import ProcessedMail | ||||||
| from paperless_mail.tests.test_mail import BogusMailBox | from paperless_mail.tests.test_mail import BogusMailBox | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -721,3 +723,285 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase): | |||||||
| 
 | 
 | ||||||
|         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) |         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||||
|         self.assertIn("maximum_age", response.data) |         self.assertIn("maximum_age", response.data) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
|  | class TestAPIProcessedMails(DirectoriesMixin, APITestCase): | ||||||
|  |     ENDPOINT = "/api/processed_mail/" | ||||||
|  | 
 | ||||||
|  |     def setUp(self): | ||||||
|  |         super().setUp() | ||||||
|  | 
 | ||||||
|  |         self.user = User.objects.create_user(username="temp_admin") | ||||||
|  |         self.user.user_permissions.add(*Permission.objects.all()) | ||||||
|  |         self.user.save() | ||||||
|  |         self.client.force_authenticate(user=self.user) | ||||||
|  | 
 | ||||||
|  |     def test_get_processed_mails_owner_aware(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Configured processed mails with different users | ||||||
|  |         WHEN: | ||||||
|  |             - API call is made to get processed mails | ||||||
|  |         THEN: | ||||||
|  |             - Only unowned, owned by user or granted processed mails are provided | ||||||
|  |         """ | ||||||
|  |         user2 = User.objects.create_user(username="temp_admin2") | ||||||
|  | 
 | ||||||
|  |         account = MailAccount.objects.create( | ||||||
|  |             name="Email1", | ||||||
|  |             username="username1", | ||||||
|  |             password="password1", | ||||||
|  |             imap_server="server.example.com", | ||||||
|  |             imap_port=443, | ||||||
|  |             imap_security=MailAccount.ImapSecurity.SSL, | ||||||
|  |             character_set="UTF-8", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         rule = MailRule.objects.create( | ||||||
|  |             name="Rule1", | ||||||
|  |             account=account, | ||||||
|  |             folder="INBOX", | ||||||
|  |             filter_from="from@example.com", | ||||||
|  |             order=0, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         pm1 = ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="1", | ||||||
|  |             subject="Subj1", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         pm2 = ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="2", | ||||||
|  |             subject="Subj2", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="FAILED", | ||||||
|  |             error="err", | ||||||
|  |             owner=self.user, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="3", | ||||||
|  |             subject="Subj3", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |             owner=user2, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         pm4 = ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="4", | ||||||
|  |             subject="Subj4", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |         ) | ||||||
|  |         pm4.owner = user2 | ||||||
|  |         pm4.save() | ||||||
|  |         assign_perm("view_processedmail", self.user, pm4) | ||||||
|  | 
 | ||||||
|  |         response = self.client.get(self.ENDPOINT) | ||||||
|  | 
 | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |         self.assertEqual(response.data["count"], 3) | ||||||
|  |         returned_ids = {r["id"] for r in response.data["results"]} | ||||||
|  |         self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id}) | ||||||
|  | 
 | ||||||
|  |     def test_get_processed_mails_filter_by_rule(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Processed mails belonging to two different rules | ||||||
|  |         WHEN: | ||||||
|  |             - API call is made with rule filter | ||||||
|  |         THEN: | ||||||
|  |             - Only processed mails for that rule are returned | ||||||
|  |         """ | ||||||
|  |         account = MailAccount.objects.create( | ||||||
|  |             name="Email1", | ||||||
|  |             username="username1", | ||||||
|  |             password="password1", | ||||||
|  |             imap_server="server.example.com", | ||||||
|  |             imap_port=443, | ||||||
|  |             imap_security=MailAccount.ImapSecurity.SSL, | ||||||
|  |             character_set="UTF-8", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         rule1 = MailRule.objects.create( | ||||||
|  |             name="Rule1", | ||||||
|  |             account=account, | ||||||
|  |             folder="INBOX", | ||||||
|  |             filter_from="from1@example.com", | ||||||
|  |             order=0, | ||||||
|  |         ) | ||||||
|  |         rule2 = MailRule.objects.create( | ||||||
|  |             name="Rule2", | ||||||
|  |             account=account, | ||||||
|  |             folder="INBOX", | ||||||
|  |             filter_from="from2@example.com", | ||||||
|  |             order=1, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         pm1 = ProcessedMail.objects.create( | ||||||
|  |             rule=rule1, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="r1-1", | ||||||
|  |             subject="R1-A", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |             owner=self.user, | ||||||
|  |         ) | ||||||
|  |         pm2 = ProcessedMail.objects.create( | ||||||
|  |             rule=rule1, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="r1-2", | ||||||
|  |             subject="R1-B", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="FAILED", | ||||||
|  |             error="e", | ||||||
|  |         ) | ||||||
|  |         ProcessedMail.objects.create( | ||||||
|  |             rule=rule2, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="r2-1", | ||||||
|  |             subject="R2-A", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}") | ||||||
|  | 
 | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |         returned_ids = {r["id"] for r in response.data["results"]} | ||||||
|  |         self.assertSetEqual(returned_ids, {pm1.id, pm2.id}) | ||||||
|  | 
 | ||||||
|  |     def test_bulk_delete_processed_mails(self): | ||||||
|  |         """ | ||||||
|  |         GIVEN: | ||||||
|  |             - Processed mails belonging to two different rules and different users | ||||||
|  |         WHEN: | ||||||
|  |             - API call is made to bulk delete some of the processed mails | ||||||
|  |         THEN: | ||||||
|  |             - Only the specified processed mails are deleted, respecting ownership and permissions | ||||||
|  |         """ | ||||||
|  |         user2 = User.objects.create_user(username="temp_admin2") | ||||||
|  | 
 | ||||||
|  |         account = MailAccount.objects.create( | ||||||
|  |             name="Email1", | ||||||
|  |             username="username1", | ||||||
|  |             password="password1", | ||||||
|  |             imap_server="server.example.com", | ||||||
|  |             imap_port=443, | ||||||
|  |             imap_security=MailAccount.ImapSecurity.SSL, | ||||||
|  |             character_set="UTF-8", | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         rule = MailRule.objects.create( | ||||||
|  |             name="Rule1", | ||||||
|  |             account=account, | ||||||
|  |             folder="INBOX", | ||||||
|  |             filter_from="from@example.com", | ||||||
|  |             order=0, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # unowned and owned by self, and one with explicit object perm | ||||||
|  |         pm_unowned = ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="u1", | ||||||
|  |             subject="Unowned", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |         ) | ||||||
|  |         pm_owned = ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="u2", | ||||||
|  |             subject="Owned", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="FAILED", | ||||||
|  |             error="e", | ||||||
|  |             owner=self.user, | ||||||
|  |         ) | ||||||
|  |         pm_granted = ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="u3", | ||||||
|  |             subject="Granted", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |             owner=user2, | ||||||
|  |         ) | ||||||
|  |         assign_perm("delete_processedmail", self.user, pm_granted) | ||||||
|  |         pm_forbidden = ProcessedMail.objects.create( | ||||||
|  |             rule=rule, | ||||||
|  |             folder="INBOX", | ||||||
|  |             uid="u4", | ||||||
|  |             subject="Forbidden", | ||||||
|  |             received=timezone.now(), | ||||||
|  |             processed=timezone.now(), | ||||||
|  |             status="SUCCESS", | ||||||
|  |             error=None, | ||||||
|  |             owner=user2, | ||||||
|  |         ) | ||||||
|  | 
 | ||||||
|  |         # Success for allowed items | ||||||
|  |         response = self.client.post( | ||||||
|  |             f"{self.ENDPOINT}bulk_delete/", | ||||||
|  |             data={ | ||||||
|  |                 "mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id], | ||||||
|  |             }, | ||||||
|  |             format="json", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_200_OK) | ||||||
|  |         self.assertEqual(response.data["result"], "OK") | ||||||
|  |         self.assertSetEqual( | ||||||
|  |             set(response.data["deleted_mail_ids"]), | ||||||
|  |             {pm_unowned.id, pm_owned.id, pm_granted.id}, | ||||||
|  |         ) | ||||||
|  |         self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists()) | ||||||
|  |         self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists()) | ||||||
|  |         self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists()) | ||||||
|  |         self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) | ||||||
|  | 
 | ||||||
|  |         # 403 and not deleted | ||||||
|  |         response = self.client.post( | ||||||
|  |             f"{self.ENDPOINT}bulk_delete/", | ||||||
|  |             data={ | ||||||
|  |                 "mail_ids": [pm_forbidden.id], | ||||||
|  |             }, | ||||||
|  |             format="json", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) | ||||||
|  |         self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists()) | ||||||
|  | 
 | ||||||
|  |         # missing mail_ids | ||||||
|  |         response = self.client.post( | ||||||
|  |             f"{self.ENDPOINT}bulk_delete/", | ||||||
|  |             data={"mail_ids": "not-a-list"}, | ||||||
|  |             format="json", | ||||||
|  |         ) | ||||||
|  |         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) | ||||||
|  | |||||||
| @ -3,8 +3,10 @@ import logging | |||||||
| from datetime import timedelta | from datetime import timedelta | ||||||
| 
 | 
 | ||||||
| from django.http import HttpResponseBadRequest | from django.http import HttpResponseBadRequest | ||||||
|  | from django.http import HttpResponseForbidden | ||||||
| from django.http import HttpResponseRedirect | from django.http import HttpResponseRedirect | ||||||
| from django.utils import timezone | from django.utils import timezone | ||||||
|  | from django_filters.rest_framework import DjangoFilterBackend | ||||||
| from drf_spectacular.types import OpenApiTypes | from drf_spectacular.types import OpenApiTypes | ||||||
| from drf_spectacular.utils import extend_schema | from drf_spectacular.utils import extend_schema | ||||||
| from drf_spectacular.utils import extend_schema_view | from drf_spectacular.utils import extend_schema_view | ||||||
| @ -12,23 +14,29 @@ from drf_spectacular.utils import inline_serializer | |||||||
| from httpx_oauth.oauth2 import GetAccessTokenError | from httpx_oauth.oauth2 import GetAccessTokenError | ||||||
| from rest_framework import serializers | from rest_framework import serializers | ||||||
| from rest_framework.decorators import action | from rest_framework.decorators import action | ||||||
|  | from rest_framework.filters import OrderingFilter | ||||||
| from rest_framework.generics import GenericAPIView | from rest_framework.generics import GenericAPIView | ||||||
| from rest_framework.permissions import IsAuthenticated | from rest_framework.permissions import IsAuthenticated | ||||||
| from rest_framework.response import Response | from rest_framework.response import Response | ||||||
| from rest_framework.viewsets import ModelViewSet | from rest_framework.viewsets import ModelViewSet | ||||||
|  | from rest_framework.viewsets import ReadOnlyModelViewSet | ||||||
| 
 | 
 | ||||||
| from documents.filters import ObjectOwnedOrGrantedPermissionsFilter | from documents.filters import ObjectOwnedOrGrantedPermissionsFilter | ||||||
| from documents.permissions import PaperlessObjectPermissions | from documents.permissions import PaperlessObjectPermissions | ||||||
|  | from documents.permissions import has_perms_owner_aware | ||||||
| from documents.views import PassUserMixin | from documents.views import PassUserMixin | ||||||
| from paperless.views import StandardPagination | from paperless.views import StandardPagination | ||||||
|  | from paperless_mail.filters import ProcessedMailFilterSet | ||||||
| from paperless_mail.mail import MailError | from paperless_mail.mail import MailError | ||||||
| from paperless_mail.mail import get_mailbox | from paperless_mail.mail import get_mailbox | ||||||
| from paperless_mail.mail import mailbox_login | from paperless_mail.mail import mailbox_login | ||||||
| from paperless_mail.models import MailAccount | from paperless_mail.models import MailAccount | ||||||
| from paperless_mail.models import MailRule | from paperless_mail.models import MailRule | ||||||
|  | from paperless_mail.models import ProcessedMail | ||||||
| from paperless_mail.oauth import PaperlessMailOAuth2Manager | from paperless_mail.oauth import PaperlessMailOAuth2Manager | ||||||
| from paperless_mail.serialisers import MailAccountSerializer | from paperless_mail.serialisers import MailAccountSerializer | ||||||
| from paperless_mail.serialisers import MailRuleSerializer | from paperless_mail.serialisers import MailRuleSerializer | ||||||
|  | from paperless_mail.serialisers import ProcessedMailSerializer | ||||||
| from paperless_mail.tasks import process_mail_accounts | from paperless_mail.tasks import process_mail_accounts | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
| @ -126,6 +134,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin): | |||||||
|         return Response({"result": "OK"}) |         return Response({"result": "OK"}) | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  | class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin): | ||||||
|  |     permission_classes = (IsAuthenticated, PaperlessObjectPermissions) | ||||||
|  |     serializer_class = ProcessedMailSerializer | ||||||
|  |     pagination_class = StandardPagination | ||||||
|  |     filter_backends = ( | ||||||
|  |         DjangoFilterBackend, | ||||||
|  |         OrderingFilter, | ||||||
|  |         ObjectOwnedOrGrantedPermissionsFilter, | ||||||
|  |     ) | ||||||
|  |     filterset_class = ProcessedMailFilterSet | ||||||
|  | 
 | ||||||
|  |     queryset = ProcessedMail.objects.all().order_by("-processed") | ||||||
|  | 
 | ||||||
|  |     @action(methods=["post"], detail=False) | ||||||
|  |     def bulk_delete(self, request): | ||||||
|  |         mail_ids = request.data.get("mail_ids", []) | ||||||
|  |         if not isinstance(mail_ids, list) or not all( | ||||||
|  |             isinstance(i, int) for i in mail_ids | ||||||
|  |         ): | ||||||
|  |             return HttpResponseBadRequest("mail_ids must be a list of integers") | ||||||
|  |         mails = ProcessedMail.objects.filter(id__in=mail_ids) | ||||||
|  |         for mail in mails: | ||||||
|  |             if not has_perms_owner_aware(request.user, "delete_processedmail", mail): | ||||||
|  |                 return HttpResponseForbidden("Insufficient permissions") | ||||||
|  |             mail.delete() | ||||||
|  |         return Response({"result": "OK", "deleted_mail_ids": mail_ids}) | ||||||
|  | 
 | ||||||
|  | 
 | ||||||
| class MailRuleViewSet(ModelViewSet, PassUserMixin): | class MailRuleViewSet(ModelViewSet, PassUserMixin): | ||||||
|     model = MailRule |     model = MailRule | ||||||
| 
 | 
 | ||||||
|  | |||||||
							
								
								
									
										8
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										8
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							| @ -882,15 +882,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]] | ||||||
| @ -2915,7 +2915,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" }, | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user