]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Feature: processed mail UI (#10866)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Mon, 22 Sep 2025 18:17:42 +0000 (11:17 -0700)
committerGitHub <noreply@github.com>
Mon, 22 Sep 2025 18:17:42 +0000 (18:17 +0000)
18 files changed:
docs/usage.md
src-ui/src/app/components/manage/mail/mail.component.html
src-ui/src/app/components/manage/mail/mail.component.spec.ts
src-ui/src/app/components/manage/mail/mail.component.ts
src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html [new file with mode: 0644]
src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss [new file with mode: 0644]
src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts [new file with mode: 0644]
src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts [new file with mode: 0644]
src-ui/src/app/data/processed-mail.ts [new file with mode: 0644]
src-ui/src/app/services/permissions.service.ts
src-ui/src/app/services/rest/processed-mail.service.spec.ts [new file with mode: 0644]
src-ui/src/app/services/rest/processed-mail.service.ts [new file with mode: 0644]
src-ui/src/main.ts
src/paperless/urls.py
src/paperless_mail/filters.py [new file with mode: 0644]
src/paperless_mail/serialisers.py
src/paperless_mail/tests/test_api.py
src/paperless_mail/views.py

index 94ef5ae1b5987276d1d4dd628126e39b694dfa9c..32441862ded57f9bad835f26919411c84fba7c85 100644 (file)
@@ -261,6 +261,10 @@ different means. These are as follows:
 Paperless is set up to check your mails every 10 minutes. This can be
 configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
 
+#### Processed Mail
+
+Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs.
+
 #### OAuth Email Setup
 
 Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
index 16e8e88fb18026896b22c0d0891b9850adc17112..97b2bf5076eef00c2bf85f314063face7f2a1928 100644 (file)
     <li class="list-group-item">
       <div class="row">
         <div class="col" i18n>Name</div>
-        <div class="col d-none d-sm-block" i18n>Sort Order</div>
-        <div class="col" i18n>Account</div>
-        <div class="col d-none d-sm-block" i18n>Status</div>
-        <div class="col" i18n>Actions</div>
+        <div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
+        <div class="col-2" i18n>Account</div>
+        <div class="col-2 d-none d-sm-block" i18n>Status</div>
+        <div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div>
+        <div class="col-3" i18n>Actions</div>
       </div>
     </li>
 
       <li class="list-group-item">
         <div class="row fade" [class.show]="showRules">
           <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
-          <div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
-          <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
-          <div class="col d-flex align-items-center d-none d-sm-flex">
+          <div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
+          <div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
+          <div class="col-2 d-flex align-items-center d-none d-sm-flex">
             <div class="form-check form-switch mb-0">
               <input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
               <label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
               </label>
             </div>
           </div>
-          <div class="col">
+          <div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
+            <button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)">
+              <i-bs width="1em" height="1em" name="clock-history"></i-bs>&nbsp;<ng-container i18n>View Processed Mail</ng-container>
+            </button>
+          </div>
+          <div class="col-3">
             <div class="btn-group d-block d-sm-none">
               <div ngbDropdown container="body" class="d-inline-block">
                 <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
index 11a40d98d25fc4a7c6a410fa2a56e30d0e3a31f1..38293aca9ccea83b3f6c13cedb94d16fcbf51e0b 100644 (file)
@@ -409,4 +409,13 @@ describe('MailComponent', () => {
     jest.advanceTimersByTime(200)
     expect(editSpy).toHaveBeenCalled()
   })
+
+  it('should open processed mails dialog', () => {
+    completeSetup()
+    let modal: NgbModalRef
+    modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
+    component.viewProcessedMail(mailRules[0] as MailRule)
+    const dialog = modal.componentInstance as any
+    expect(dialog.rule).toEqual(mailRules[0])
+  })
 })
index 06e2570ee71416d0aa8b352c868553829f9b58d9..825ca1ffdc3a985e0640a70acbc5d8af5d052158 100644 (file)
@@ -27,6 +27,7 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
 import { PageHeaderComponent } from '../../common/page-header/page-header.component'
 import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
 import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
+import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
 
 @Component({
   selector: 'pngx-mail',
@@ -347,6 +348,14 @@ export class MailComponent
     )
   }
 
+  viewProcessedMail(rule: MailRule) {
+    const modal = this.modalService.open(ProcessedMailDialogComponent, {
+      backdrop: 'static',
+      size: 'xl',
+    })
+    modal.componentInstance.rule = rule
+  }
+
   userCanEdit(obj: ObjectWithPermissions): boolean {
     return this.permissionsService.currentUserHasObjectPermissions(
       PermissionAction.Change,
diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.html
new file mode 100644 (file)
index 0000000..604e3fd
--- /dev/null
@@ -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>
diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.scss
new file mode 100644 (file)
index 0000000..6aadd83
--- /dev/null
@@ -0,0 +1,8 @@
+::ng-deep .popover {
+    max-width: 350px;
+
+    pre {
+        white-space: pre-wrap;
+        word-break: break-word;
+    }
+}
diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.spec.ts
new file mode 100644 (file)
index 0000000..c34c97e
--- /dev/null
@@ -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()
+  })
+})
diff --git a/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts b/src-ui/src/app/components/manage/mail/processed-mail-dialog/processed-mail-dialog.component.ts
new file mode 100644 (file)
index 0000000..ed51ad0
--- /dev/null
@@ -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)
+  }
+}
diff --git a/src-ui/src/app/data/processed-mail.ts b/src-ui/src/app/data/processed-mail.ts
new file mode 100644 (file)
index 0000000..7eacf24
--- /dev/null
@@ -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
+}
index 3d88b10cce6e7882dd3efaaf5e21aab224111803..0c36b646f5212c66473e6f61eb1c263cf2412eee 100644 (file)
@@ -28,6 +28,7 @@ export enum PermissionType {
   ShareLink = '%s_sharelink',
   CustomField = '%s_customfield',
   Workflow = '%s_workflow',
+  ProcessedMail = '%s_processedmail',
 }
 
 @Injectable({
diff --git a/src-ui/src/app/services/rest/processed-mail.service.spec.ts b/src-ui/src/app/services/rest/processed-mail.service.spec.ts
new file mode 100644 (file)
index 0000000..a424c2c
--- /dev/null
@@ -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({})
+  })
+})
diff --git a/src-ui/src/app/services/rest/processed-mail.service.ts b/src-ui/src/app/services/rest/processed-mail.service.ts
new file mode 100644 (file)
index 0000000..e1ea327
--- /dev/null
@@ -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,
+    })
+  }
+}
index cd1f4ef59d2c037e3e3c4713a9133c2ccc0afeb2..7e57edcea13f906eab04870d7b917db9ab5025f7 100644 (file)
@@ -51,6 +51,7 @@ import {
   check,
   check2All,
   checkAll,
+  checkCircle,
   checkCircleFill,
   checkLg,
   chevronDoubleLeft,
@@ -60,6 +61,7 @@ import {
   clipboardCheck,
   clipboardCheckFill,
   clipboardFill,
+  clockHistory,
   dash,
   dashCircle,
   diagram3,
@@ -263,6 +265,7 @@ const icons = {
   check,
   check2All,
   checkAll,
+  checkCircle,
   checkCircleFill,
   checkLg,
   chevronDoubleLeft,
@@ -272,6 +275,7 @@ const icons = {
   clipboardCheck,
   clipboardCheckFill,
   clipboardFill,
+  clockHistory,
   dash,
   dashCircle,
   diagram3,
index c37331ce283ee0271fd7cc2472192333f51dcd91..e24d1a459173fb22fe50f325670703d68adde820 100644 (file)
@@ -57,6 +57,7 @@ from paperless.views import UserViewSet
 from paperless_mail.views import MailAccountViewSet
 from paperless_mail.views import MailRuleViewSet
 from paperless_mail.views import OauthCallbackView
+from paperless_mail.views import ProcessedMailViewSet
 
 api_router = DefaultRouter()
 api_router.register(r"correspondents", CorrespondentViewSet)
@@ -77,6 +78,7 @@ api_router.register(r"workflow_actions", WorkflowActionViewSet)
 api_router.register(r"workflows", WorkflowViewSet)
 api_router.register(r"custom_fields", CustomFieldViewSet)
 api_router.register(r"config", ApplicationConfigurationViewSet)
+api_router.register(r"processed_mail", ProcessedMailViewSet)
 
 
 urlpatterns = [
diff --git a/src/paperless_mail/filters.py b/src/paperless_mail/filters.py
new file mode 100644 (file)
index 0000000..57b8dec
--- /dev/null
@@ -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"],
+        }
index fa025fcbe3a7bc48adbe6d9d743a88e0e89acf6a..b38c8e78c08f8b1f6eb8ad52dfd65692ac0fef72 100644 (file)
@@ -6,6 +6,7 @@ from documents.serialisers import OwnedObjectSerializer
 from documents.serialisers import TagsField
 from paperless_mail.models import MailAccount
 from paperless_mail.models import MailRule
+from paperless_mail.models import ProcessedMail
 
 
 class ObfuscatedPasswordField(serializers.CharField):
@@ -130,3 +131,20 @@ class MailRuleSerializer(OwnedObjectSerializer):
         if value > 36500:  # ~100 years
             raise serializers.ValidationError("Maximum mail age is unreasonably large.")
         return value
+
+
+class ProcessedMailSerializer(OwnedObjectSerializer):
+    class Meta:
+        model = ProcessedMail
+        fields = [
+            "id",
+            "owner",
+            "rule",
+            "folder",
+            "uid",
+            "subject",
+            "received",
+            "processed",
+            "status",
+            "error",
+        ]
index 3ba06a746ace1d56dc8d130c838e232b026eab4e..dd63c67abfd5159e0159a5f90530e921ec843b8d 100644 (file)
@@ -3,6 +3,7 @@ from unittest import mock
 
 from django.contrib.auth.models import Permission
 from django.contrib.auth.models import User
+from django.utils import timezone
 from guardian.shortcuts import assign_perm
 from rest_framework import status
 from rest_framework.test import APITestCase
@@ -13,6 +14,7 @@ from documents.models import Tag
 from documents.tests.utils import DirectoriesMixin
 from paperless_mail.models import MailAccount
 from paperless_mail.models import MailRule
+from paperless_mail.models import ProcessedMail
 from paperless_mail.tests.test_mail import BogusMailBox
 
 
@@ -721,3 +723,285 @@ class TestAPIMailRules(DirectoriesMixin, APITestCase):
 
         self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
         self.assertIn("maximum_age", response.data)
+
+
+class TestAPIProcessedMails(DirectoriesMixin, APITestCase):
+    ENDPOINT = "/api/processed_mail/"
+
+    def setUp(self):
+        super().setUp()
+
+        self.user = User.objects.create_user(username="temp_admin")
+        self.user.user_permissions.add(*Permission.objects.all())
+        self.user.save()
+        self.client.force_authenticate(user=self.user)
+
+    def test_get_processed_mails_owner_aware(self):
+        """
+        GIVEN:
+            - Configured processed mails with different users
+        WHEN:
+            - API call is made to get processed mails
+        THEN:
+            - Only unowned, owned by user or granted processed mails are provided
+        """
+        user2 = User.objects.create_user(username="temp_admin2")
+
+        account = MailAccount.objects.create(
+            name="Email1",
+            username="username1",
+            password="password1",
+            imap_server="server.example.com",
+            imap_port=443,
+            imap_security=MailAccount.ImapSecurity.SSL,
+            character_set="UTF-8",
+        )
+
+        rule = MailRule.objects.create(
+            name="Rule1",
+            account=account,
+            folder="INBOX",
+            filter_from="from@example.com",
+            order=0,
+        )
+
+        pm1 = ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="1",
+            subject="Subj1",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+        )
+
+        pm2 = ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="2",
+            subject="Subj2",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="FAILED",
+            error="err",
+            owner=self.user,
+        )
+
+        ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="3",
+            subject="Subj3",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+            owner=user2,
+        )
+
+        pm4 = ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="4",
+            subject="Subj4",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+        )
+        pm4.owner = user2
+        pm4.save()
+        assign_perm("view_processedmail", self.user, pm4)
+
+        response = self.client.get(self.ENDPOINT)
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data["count"], 3)
+        returned_ids = {r["id"] for r in response.data["results"]}
+        self.assertSetEqual(returned_ids, {pm1.id, pm2.id, pm4.id})
+
+    def test_get_processed_mails_filter_by_rule(self):
+        """
+        GIVEN:
+            - Processed mails belonging to two different rules
+        WHEN:
+            - API call is made with rule filter
+        THEN:
+            - Only processed mails for that rule are returned
+        """
+        account = MailAccount.objects.create(
+            name="Email1",
+            username="username1",
+            password="password1",
+            imap_server="server.example.com",
+            imap_port=443,
+            imap_security=MailAccount.ImapSecurity.SSL,
+            character_set="UTF-8",
+        )
+
+        rule1 = MailRule.objects.create(
+            name="Rule1",
+            account=account,
+            folder="INBOX",
+            filter_from="from1@example.com",
+            order=0,
+        )
+        rule2 = MailRule.objects.create(
+            name="Rule2",
+            account=account,
+            folder="INBOX",
+            filter_from="from2@example.com",
+            order=1,
+        )
+
+        pm1 = ProcessedMail.objects.create(
+            rule=rule1,
+            folder="INBOX",
+            uid="r1-1",
+            subject="R1-A",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+            owner=self.user,
+        )
+        pm2 = ProcessedMail.objects.create(
+            rule=rule1,
+            folder="INBOX",
+            uid="r1-2",
+            subject="R1-B",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="FAILED",
+            error="e",
+        )
+        ProcessedMail.objects.create(
+            rule=rule2,
+            folder="INBOX",
+            uid="r2-1",
+            subject="R2-A",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+        )
+
+        response = self.client.get(f"{self.ENDPOINT}?rule={rule1.pk}")
+
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        returned_ids = {r["id"] for r in response.data["results"]}
+        self.assertSetEqual(returned_ids, {pm1.id, pm2.id})
+
+    def test_bulk_delete_processed_mails(self):
+        """
+        GIVEN:
+            - Processed mails belonging to two different rules and different users
+        WHEN:
+            - API call is made to bulk delete some of the processed mails
+        THEN:
+            - Only the specified processed mails are deleted, respecting ownership and permissions
+        """
+        user2 = User.objects.create_user(username="temp_admin2")
+
+        account = MailAccount.objects.create(
+            name="Email1",
+            username="username1",
+            password="password1",
+            imap_server="server.example.com",
+            imap_port=443,
+            imap_security=MailAccount.ImapSecurity.SSL,
+            character_set="UTF-8",
+        )
+
+        rule = MailRule.objects.create(
+            name="Rule1",
+            account=account,
+            folder="INBOX",
+            filter_from="from@example.com",
+            order=0,
+        )
+
+        # unowned and owned by self, and one with explicit object perm
+        pm_unowned = ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="u1",
+            subject="Unowned",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+        )
+        pm_owned = ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="u2",
+            subject="Owned",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="FAILED",
+            error="e",
+            owner=self.user,
+        )
+        pm_granted = ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="u3",
+            subject="Granted",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+            owner=user2,
+        )
+        assign_perm("delete_processedmail", self.user, pm_granted)
+        pm_forbidden = ProcessedMail.objects.create(
+            rule=rule,
+            folder="INBOX",
+            uid="u4",
+            subject="Forbidden",
+            received=timezone.now(),
+            processed=timezone.now(),
+            status="SUCCESS",
+            error=None,
+            owner=user2,
+        )
+
+        # Success for allowed items
+        response = self.client.post(
+            f"{self.ENDPOINT}bulk_delete/",
+            data={
+                "mail_ids": [pm_unowned.id, pm_owned.id, pm_granted.id],
+            },
+            format="json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_200_OK)
+        self.assertEqual(response.data["result"], "OK")
+        self.assertSetEqual(
+            set(response.data["deleted_mail_ids"]),
+            {pm_unowned.id, pm_owned.id, pm_granted.id},
+        )
+        self.assertFalse(ProcessedMail.objects.filter(id=pm_unowned.id).exists())
+        self.assertFalse(ProcessedMail.objects.filter(id=pm_owned.id).exists())
+        self.assertFalse(ProcessedMail.objects.filter(id=pm_granted.id).exists())
+        self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists())
+
+        # 403 and not deleted
+        response = self.client.post(
+            f"{self.ENDPOINT}bulk_delete/",
+            data={
+                "mail_ids": [pm_forbidden.id],
+            },
+            format="json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
+        self.assertTrue(ProcessedMail.objects.filter(id=pm_forbidden.id).exists())
+
+        # missing mail_ids
+        response = self.client.post(
+            f"{self.ENDPOINT}bulk_delete/",
+            data={"mail_ids": "not-a-list"},
+            format="json",
+        )
+        self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
index e48049a36b2ed5364a1476658d4c43403316b80b..b54bcb5f71f1d80e11a9bd06dde329dc2d30bb8b 100644 (file)
@@ -3,8 +3,10 @@ import logging
 from datetime import timedelta
 
 from django.http import HttpResponseBadRequest
+from django.http import HttpResponseForbidden
 from django.http import HttpResponseRedirect
 from django.utils import timezone
+from django_filters.rest_framework import DjangoFilterBackend
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema
 from drf_spectacular.utils import extend_schema_view
@@ -12,23 +14,29 @@ from drf_spectacular.utils import inline_serializer
 from httpx_oauth.oauth2 import GetAccessTokenError
 from rest_framework import serializers
 from rest_framework.decorators import action
+from rest_framework.filters import OrderingFilter
 from rest_framework.generics import GenericAPIView
 from rest_framework.permissions import IsAuthenticated
 from rest_framework.response import Response
 from rest_framework.viewsets import ModelViewSet
+from rest_framework.viewsets import ReadOnlyModelViewSet
 
 from documents.filters import ObjectOwnedOrGrantedPermissionsFilter
 from documents.permissions import PaperlessObjectPermissions
+from documents.permissions import has_perms_owner_aware
 from documents.views import PassUserMixin
 from paperless.views import StandardPagination
+from paperless_mail.filters import ProcessedMailFilterSet
 from paperless_mail.mail import MailError
 from paperless_mail.mail import get_mailbox
 from paperless_mail.mail import mailbox_login
 from paperless_mail.models import MailAccount
 from paperless_mail.models import MailRule
+from paperless_mail.models import ProcessedMail
 from paperless_mail.oauth import PaperlessMailOAuth2Manager
 from paperless_mail.serialisers import MailAccountSerializer
 from paperless_mail.serialisers import MailRuleSerializer
+from paperless_mail.serialisers import ProcessedMailSerializer
 from paperless_mail.tasks import process_mail_accounts
 
 
@@ -126,6 +134,34 @@ class MailAccountViewSet(ModelViewSet, PassUserMixin):
         return Response({"result": "OK"})
 
 
+class ProcessedMailViewSet(ReadOnlyModelViewSet, PassUserMixin):
+    permission_classes = (IsAuthenticated, PaperlessObjectPermissions)
+    serializer_class = ProcessedMailSerializer
+    pagination_class = StandardPagination
+    filter_backends = (
+        DjangoFilterBackend,
+        OrderingFilter,
+        ObjectOwnedOrGrantedPermissionsFilter,
+    )
+    filterset_class = ProcessedMailFilterSet
+
+    queryset = ProcessedMail.objects.all().order_by("-processed")
+
+    @action(methods=["post"], detail=False)
+    def bulk_delete(self, request):
+        mail_ids = request.data.get("mail_ids", [])
+        if not isinstance(mail_ids, list) or not all(
+            isinstance(i, int) for i in mail_ids
+        ):
+            return HttpResponseBadRequest("mail_ids must be a list of integers")
+        mails = ProcessedMail.objects.filter(id__in=mail_ids)
+        for mail in mails:
+            if not has_perms_owner_aware(request.user, "delete_processedmail", mail):
+                return HttpResponseForbidden("Insufficient permissions")
+            mail.delete()
+        return Response({"result": "OK", "deleted_mail_ids": mail_ids})
+
+
 class MailRuleViewSet(ModelViewSet, PassUserMixin):
     model = MailRule