diff --git a/docs/advanced_usage.md b/docs/advanced_usage.md index 89530db7f..957d5287e 100644 --- a/docs/advanced_usage.md +++ b/docs/advanced_usage.md @@ -589,6 +589,12 @@ case, Paperless will remove the staging copy as well as the scan, and give you a message asking you to restart the process from scratch, by scanning the odd pages again, followed by the even pages. +It's important that the scan files get consumed in the correct order, and one at a time. +You therefore need to make sure that Paperless is running while you upload the files into +the directory; and if you're using [polling](/configuration#polling), make sure that +`CONSUMER_POLLING` is set to a value lower than it takes for the second scan to appear, +like 5-10 or even lower. + Another thing that might happen is that you start a double sided scan, but then forget to upload the second file. To avoid collating the wrong documents if you then come back a day later to scan a new double-sided document, Paperless will only keep an "odd numbered @@ -597,11 +603,11 @@ scan a completely new "odd numbered pages" one. The old staging file will get di ### Interaction with "subdirs as tags" -The collation feature can be used together with the "subdirs as tags" feature (but this is not -a requirement). Just create a correctly named double-sided subdir in the hierachy and upload -your scans there. For example, both `double-sided/foo/bar` as well as `foo/bar/double-sided` will -cause the collated document to be treated as if it were uploaded into `foo/bar` and receive both -`foo` and `bar` tags, but not `double-sided`. +The collation feature can be used together with the [subdirs as tags](/configuration#consume_config) +feature (but this is not a requirement). Just create a correctly named double-sided subdir +in the hierachy and upload your scans there. For example, both `double-sided/foo/bar` as +well as `foo/bar/double-sided` will cause the collated document to be treated as if it +were uploaded into `foo/bar` and receive both `foo` and `bar` tags, but not `double-sided`. ### Interaction with document splitting diff --git a/docs/configuration.md b/docs/configuration.md index 0ed2218a6..74486660f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,12 @@ matcher. Defaults to `redis://localhost:6379`. +`PAPERLESS_REDIS_PREFIX=` + +: Prefix to be used in Redis for keys and channels. Useful for sharing one Redis server among multiple Paperless instances. + + Defaults to no prefix. + ### Database `PAPERLESS_DBENGINE=` @@ -495,6 +501,19 @@ HTTP header/value expected by Django, eg `'["HTTP_X_FORWARDED_PROTO", "https"]'` Settings this value has security implications. Read the Django documentation and be sure you understand its usage before setting it. +`PAPERLESS_EMAIL_CERTIFICATE_FILE=` + +: Configures an additional SSL certificate file containing a [certificate](https://docs.python.org/3/library/ssl.html#certificates) +or certificate chain which should be trusted for validating SSL connections against mail providers. +This is for use with self-signed certificates against local IMAP servers. + + Defaults to None. + +!!! warning + + Settings this value has security implications for the security of your email. + Understand what it does and be sure you need to before setting. + ## OCR settings {#ocr} Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/) diff --git a/src-ui/messages.xlf b/src-ui/messages.xlf index 865591166..1d07e98e4 100644 --- a/src-ui/messages.xlf +++ b/src-ui/messages.xlf @@ -723,7 +723,7 @@ src/app/components/manage/settings/settings.component.ts - 600 + 648 @@ -2913,19 +2913,19 @@ src/app/components/manage/settings/settings.component.ts - 711 + 759 src/app/components/manage/settings/settings.component.ts - 771 + 819 src/app/components/manage/settings/settings.component.ts - 838 + 886 src/app/components/manage/settings/settings.component.ts - 901 + 949 @@ -2940,19 +2940,19 @@ src/app/components/manage/settings/settings.component.ts - 713 + 761 src/app/components/manage/settings/settings.component.ts - 773 + 821 src/app/components/manage/settings/settings.component.ts - 840 + 888 src/app/components/manage/settings/settings.component.ts - 903 + 951 @@ -4489,235 +4489,263 @@ 372 + + Error retrieving groups + + src/app/components/manage/settings/settings.component.ts + 278 + + + + Error retrieving users + + src/app/components/manage/settings/settings.component.ts + 287 + + + + Error retrieving mail rules + + src/app/components/manage/settings/settings.component.ts + 314 + + + + Error retrieving mail accounts + + src/app/components/manage/settings/settings.component.ts + 323 + + Saved view "" deleted. src/app/components/manage/settings/settings.component.ts - 482 + 530 Settings saved src/app/components/manage/settings/settings.component.ts - 584 + 632 Settings were saved successfully. src/app/components/manage/settings/settings.component.ts - 585 + 633 Settings were saved successfully. Reload is required to apply some changes. src/app/components/manage/settings/settings.component.ts - 589 + 637 Reload now src/app/components/manage/settings/settings.component.ts - 590 + 638 Use system language src/app/components/manage/settings/settings.component.ts - 609 + 657 Use date format of display language src/app/components/manage/settings/settings.component.ts - 616 + 664 Error while storing settings on server. src/app/components/manage/settings/settings.component.ts - 636 + 684 Password has been changed, you will be logged out momentarily. src/app/components/manage/settings/settings.component.ts - 679 + 727 Saved user "". src/app/components/manage/settings/settings.component.ts - 686 + 734 Error saving user. src/app/components/manage/settings/settings.component.ts - 698 + 746 Confirm delete user account src/app/components/manage/settings/settings.component.ts - 709 + 757 This operation will permanently delete this user account. src/app/components/manage/settings/settings.component.ts - 710 + 758 Deleted user src/app/components/manage/settings/settings.component.ts - 719 + 767 Error deleting user. src/app/components/manage/settings/settings.component.ts - 727 + 775 Saved group "". src/app/components/manage/settings/settings.component.ts - 748 + 796 Error saving group. src/app/components/manage/settings/settings.component.ts - 758 + 806 Confirm delete user group src/app/components/manage/settings/settings.component.ts - 769 + 817 This operation will permanently delete this user group. src/app/components/manage/settings/settings.component.ts - 770 + 818 Deleted group src/app/components/manage/settings/settings.component.ts - 779 + 827 Error deleting group. src/app/components/manage/settings/settings.component.ts - 787 + 835 Saved account "". src/app/components/manage/settings/settings.component.ts - 813 + 861 Error saving account. src/app/components/manage/settings/settings.component.ts - 825 + 873 Confirm delete mail account src/app/components/manage/settings/settings.component.ts - 836 + 884 This operation will permanently delete this mail account. src/app/components/manage/settings/settings.component.ts - 837 + 885 Deleted mail account src/app/components/manage/settings/settings.component.ts - 846 + 894 Error deleting mail account. src/app/components/manage/settings/settings.component.ts - 855 + 903 Saved rule "". src/app/components/manage/settings/settings.component.ts - 876 + 924 Error saving rule. src/app/components/manage/settings/settings.component.ts - 888 + 936 Confirm delete mail rule src/app/components/manage/settings/settings.component.ts - 899 + 947 This operation will permanently delete this mail rule. src/app/components/manage/settings/settings.component.ts - 900 + 948 Deleted mail rule src/app/components/manage/settings/settings.component.ts - 909 + 957 Error deleting mail rule. src/app/components/manage/settings/settings.component.ts - 918 + 966 diff --git a/src-ui/src/app/components/common/input/tags/tags.component.html b/src-ui/src/app/components/common/input/tags/tags.component.html index eba8ef218..497a62335 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.html +++ b/src-ui/src/app/components/common/input/tags/tags.component.html @@ -2,7 +2,7 @@
- + (change)="onChange(value)"> diff --git a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts index f3ea05d5d..85c492aba 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.spec.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.spec.ts @@ -15,16 +15,28 @@ import { DEFAULT_MATCHING_ALGORITHM, MATCH_ALL, } from 'src/app/data/matching-model' -import { NgSelectModule } from '@ng-select/ng-select' +import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select' import { RouterTestingModule } from '@angular/router/testing' import { HttpClientTestingModule } from '@angular/common/http/testing' import { of } from 'rxjs' import { TagService } from 'src/app/services/rest/tag.service' import { + NgbAccordionModule, NgbModal, NgbModalModule, NgbModalRef, + NgbPopoverModule, } from '@ng-bootstrap/ng-bootstrap' +import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component' +import { CheckComponent } from '../check/check.component' +import { IfOwnerDirective } from 'src/app/directives/if-owner.directive' +import { TextComponent } from '../text/text.component' +import { ColorComponent } from '../color/color.component' +import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' +import { PermissionsFormComponent } from '../permissions/permissions-form/permissions-form.component' +import { SelectComponent } from '../select/select.component' +import { ColorSliderModule } from 'ngx-color/slider' +import { By } from '@angular/platform-browser' const tags: PaperlessTag[] = [ { @@ -56,12 +68,32 @@ describe('TagsComponent', () => { beforeEach(async () => { TestBed.configureTestingModule({ - declarations: [TagsComponent], + declarations: [ + TagsComponent, + TagEditDialogComponent, + TextComponent, + ColorComponent, + IfOwnerDirective, + SelectComponent, + TextComponent, + PermissionsFormComponent, + ColorComponent, + CheckComponent, + ], providers: [ { provide: TagService, useValue: { - listAll: () => of(tags), + listAll: () => + of({ + results: tags, + }), + create: () => + of({ + name: 'bar', + id: 99, + color: '#fff000', + }), }, }, ], @@ -72,6 +104,8 @@ describe('TagsComponent', () => { RouterTestingModule, HttpClientTestingModule, NgbModalModule, + NgbAccordionModule, + NgbPopoverModule, ], }).compileComponents() @@ -85,7 +119,7 @@ describe('TagsComponent', () => { }) it('should support suggestions', () => { - expect(component.value).toBeUndefined() + expect(component.value).toHaveLength(0) component.value = [] component.tags = tags component.suggestions = [1, 2] @@ -107,19 +141,19 @@ describe('TagsComponent', () => { it('should support create new using last search term and open a modal', () => { let activeInstances: NgbModalRef[] modalService.activeInstances.subscribe((v) => (activeInstances = v)) - component.onSearch({ term: 'bar' }) + component.select.searchTerm = 'foobar' component.createTag() expect(modalService.hasOpenModals()).toBeTruthy() - expect(activeInstances[0].componentInstance.object.name).toEqual('bar') + expect(activeInstances[0].componentInstance.object.name).toEqual('foobar') + const editDialog = activeInstances[0] + .componentInstance as TagEditDialogComponent + editDialog.save() // create is mocked + fixture.detectChanges() + fixture.whenStable().then(() => { + expect(fixture.debugElement.nativeElement.textContent).toContain('foobar') + }) }) - it('should clear search term on blur after delay', fakeAsync(() => { - const clearSpy = jest.spyOn(component, 'clearLastSearchTerm') - component.onBlur() - tick(3000) - expect(clearSpy).toHaveBeenCalled() - })) - it('support remove tags', () => { component.tags = tags component.value = [1, 2] @@ -132,6 +166,7 @@ describe('TagsComponent', () => { }) it('should get tags', () => { + component.tags = null expect(component.getTag(2)).toBeNull() component.tags = tags expect(component.getTag(2)).toEqual(tags[1]) diff --git a/src-ui/src/app/components/common/input/tags/tags.component.ts b/src-ui/src/app/components/common/input/tags/tags.component.ts index 4fb0151b6..b6bfddb3c 100644 --- a/src-ui/src/app/components/common/input/tags/tags.component.ts +++ b/src-ui/src/app/components/common/input/tags/tags.component.ts @@ -5,6 +5,7 @@ import { Input, OnInit, Output, + ViewChild, } from '@angular/core' import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' @@ -12,6 +13,8 @@ import { PaperlessTag } from 'src/app/data/paperless-tag' import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagService } from 'src/app/services/rest/tag.service' import { EditDialogMode } from '../../edit-dialog/edit-dialog.component' +import { first, firstValueFrom, tap } from 'rxjs' +import { NgSelectComponent } from '@ng-select/ng-select' @Component({ providers: [ @@ -74,14 +77,14 @@ export class TagsComponent implements OnInit, ControlValueAccessor { @Output() filterDocuments = new EventEmitter() - value: number[] + @ViewChild('tagSelect') select: NgSelectComponent - tags: PaperlessTag[] + value: number[] = [] + + tags: PaperlessTag[] = [] public createTagRef: (name) => void - private _lastSearchTerm: string - getTag(id: number) { if (this.tags) { return this.tags.find((tag) => tag.id == id) @@ -111,15 +114,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor { }) modal.componentInstance.dialogMode = EditDialogMode.CREATE if (name) modal.componentInstance.object = { name: name } - else if (this._lastSearchTerm) - modal.componentInstance.object = { name: this._lastSearchTerm } - modal.componentInstance.succeeded.subscribe((newTag) => { - this.tagService.listAll().subscribe((tags) => { - this.tags = tags.results - this.value = [...this.value, newTag.id] - this.onChange(this.value) - }) - }) + else if (this.select.searchTerm) + modal.componentInstance.object = { name: this.select.searchTerm } + this.select.searchTerm = null + this.select.detectChanges() + return firstValueFrom( + (modal.componentInstance as TagEditDialogComponent).succeeded.pipe( + first(), + tap(() => { + this.tagService.listAll().subscribe((tags) => { + this.tags = tags.results + }) + }) + ) + ) } getSuggestions() { @@ -137,20 +145,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor { this.onChange(this.value) } - clearLastSearchTerm() { - this._lastSearchTerm = null - } - - onSearch($event) { - this._lastSearchTerm = $event.term - } - - onBlur() { - setTimeout(() => { - this.clearLastSearchTerm() - }, 3000) - } - get hasPrivate(): boolean { return this.value.some( (t) => this.tags?.find((t2) => t2.id === t) === undefined diff --git a/src-ui/src/app/components/manage/settings/settings.component.html b/src-ui/src/app/components/manage/settings/settings.component.html index 5090d531d..8b0132902 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.html +++ b/src-ui/src/app/components/manage/settings/settings.component.html @@ -243,7 +243,7 @@

Mail accounts -

+
{{account.imap_server}}
@@ -280,7 +280,7 @@

Mail rules -

+
{{(mailAccountService.getCached(rule.account) | async)?.name}}
@@ -323,7 +323,7 @@ -
  • +
  • Users & Groups @@ -334,7 +334,7 @@ - Add User + Add User
      @@ -350,13 +350,13 @@
    • -
      +
      {{user.first_name}} {{user.last_name}}
      {{user.groups?.map(getGroupName, this).join(', ')}}
      - - + +
      @@ -369,7 +369,7 @@ - Add Group + Add Group
        @@ -385,13 +385,13 @@
      • -
        +
        - - + +
        diff --git a/src-ui/src/app/components/manage/settings/settings.component.spec.ts b/src-ui/src/app/components/manage/settings/settings.component.spec.ts index c4a9d4a4b..fb8f0a7f4 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.spec.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.spec.ts @@ -15,6 +15,7 @@ import { NgbModule, NgbNavLink, NgbModalRef, + NgbAlertModule, } from '@ng-bootstrap/ng-bootstrap' import { of, throwError } from 'rxjs' import { routes } from 'src/app/app-routing.module' @@ -42,6 +43,13 @@ import { CheckComponent } from '../../common/input/check/check.component' import { ColorComponent } from '../../common/input/color/color.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { SettingsComponent } from './settings.component' +import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' +import { SelectComponent } from '../../common/input/select/select.component' +import { TextComponent } from '../../common/input/text/text.component' +import { PasswordComponent } from '../../common/input/password/password.component' +import { NumberComponent } from '../../common/input/number/number.component' +import { TagsComponent } from '../../common/input/tags/tags.component' +import { NgSelectModule } from '@ng-select/ng-select' const savedViews = [ { id: 1, name: 'view1' }, @@ -90,6 +98,14 @@ describe('SettingsComponent', () => { ConfirmDialogComponent, CheckComponent, ColorComponent, + SafeHtmlPipe, + SelectComponent, + TextComponent, + PasswordComponent, + NumberComponent, + TagsComponent, + MailAccountEditDialogComponent, + MailRuleEditDialogComponent, ], providers: [CustomDatePipe, DatePipe, PermissionsGuard], imports: [ @@ -98,6 +114,8 @@ describe('SettingsComponent', () => { RouterTestingModule.withRoutes(routes), FormsModule, ReactiveFormsModule, + NgbAlertModule, + NgSelectModule, ], }).compileComponents() @@ -116,52 +134,66 @@ describe('SettingsComponent', () => { jest .spyOn(permissionsService, 'currentUserOwnsObject') .mockReturnValue(true) - jest.spyOn(userService, 'listAll').mockReturnValue( - of({ - all: users.map((u) => u.id), - count: users.length, - results: users.concat([]), - }) - ) groupService = TestBed.inject(GroupService) - jest.spyOn(groupService, 'listAll').mockReturnValue( - of({ - all: groups.map((g) => g.id), - count: groups.length, - results: groups.concat([]), - }) - ) savedViewService = TestBed.inject(SavedViewService) - jest.spyOn(savedViewService, 'listAll').mockReturnValue( - of({ - all: savedViews.map((v) => v.id), - count: savedViews.length, - results: (savedViews as PaperlessSavedView[]).concat([]), - }) - ) mailAccountService = TestBed.inject(MailAccountService) - jest.spyOn(mailAccountService, 'listAll').mockReturnValue( - of({ - all: mailAccounts.map((a) => a.id), - count: mailAccounts.length, - results: (mailAccounts as PaperlessMailAccount[]).concat([]), - }) - ) mailRuleService = TestBed.inject(MailRuleService) - jest.spyOn(mailRuleService, 'listAll').mockReturnValue( - of({ - all: mailRules.map((r) => r.id), - count: mailRules.length, - results: (mailRules as PaperlessMailRule[]).concat([]), - }) - ) + }) + + function completeSetup(excludeService = null) { + if (excludeService !== userService) { + jest.spyOn(userService, 'listAll').mockReturnValue( + of({ + all: users.map((u) => u.id), + count: users.length, + results: users.concat([]), + }) + ) + } + if (excludeService !== groupService) { + jest.spyOn(groupService, 'listAll').mockReturnValue( + of({ + all: groups.map((g) => g.id), + count: groups.length, + results: groups.concat([]), + }) + ) + } + if (excludeService !== savedViewService) { + jest.spyOn(savedViewService, 'listAll').mockReturnValue( + of({ + all: savedViews.map((v) => v.id), + count: savedViews.length, + results: (savedViews as PaperlessSavedView[]).concat([]), + }) + ) + } + if (excludeService !== mailAccountService) { + jest.spyOn(mailAccountService, 'listAll').mockReturnValue( + of({ + all: mailAccounts.map((a) => a.id), + count: mailAccounts.length, + results: (mailAccounts as PaperlessMailAccount[]).concat([]), + }) + ) + } + if (excludeService !== mailRuleService) { + jest.spyOn(mailRuleService, 'listAll').mockReturnValue( + of({ + all: mailRules.map((r) => r.id), + count: mailRules.length, + results: (mailRules as PaperlessMailRule[]).concat([]), + }) + ) + } fixture = TestBed.createComponent(SettingsComponent) component = fixture.componentInstance fixture.detectChanges() - }) + } it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => { + completeSetup() const navigateSpy = jest.spyOn(router, 'navigate') const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) tabButtons[1].nativeElement.dispatchEvent(new MouseEvent('click')) @@ -187,6 +219,7 @@ describe('SettingsComponent', () => { }) it('should support direct link to tab by URL, scroll if needed', () => { + completeSetup() jest .spyOn(activatedRoute, 'paramMap', 'get') .mockReturnValue(of(convertToParamMap({ section: 'mail' }))) @@ -199,6 +232,7 @@ describe('SettingsComponent', () => { }) it('should lazy load tab data', () => { + completeSetup() const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) expect(component.savedViews).toBeUndefined() @@ -221,6 +255,7 @@ describe('SettingsComponent', () => { }) it('should support save saved views, show error', () => { + completeSetup() component.maybeInitializeTab(3) // SavedViews const toastErrorSpy = jest.spyOn(toastService, 'showError') @@ -248,6 +283,7 @@ describe('SettingsComponent', () => { }) it('should support save local settings updating appearance settings and calling API, show error', () => { + completeSetup() const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'show') const storeSpy = jest.spyOn(settingsService, 'storeSettings') @@ -275,6 +311,7 @@ describe('SettingsComponent', () => { }) it('should offer reload if settings changes require', () => { + completeSetup() let toast: Toast toastService.getToasts().subscribe((t) => (toast = t[0])) component.initialize(true) // reset @@ -288,6 +325,7 @@ describe('SettingsComponent', () => { }) it('should allow setting theme color, visually apply change immediately but not save', () => { + completeSetup() const appearanceSpy = jest.spyOn( settingsService, 'updateAppearanceSettings' @@ -304,6 +342,7 @@ describe('SettingsComponent', () => { }) it('should support delete saved view', () => { + completeSetup() component.maybeInitializeTab(3) // SavedViews const toastSpy = jest.spyOn(toastService, 'showInfo') const deleteSpy = jest.spyOn(savedViewService, 'delete') @@ -316,6 +355,7 @@ describe('SettingsComponent', () => { }) it('should support edit / create user, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) @@ -332,6 +372,7 @@ describe('SettingsComponent', () => { }) it('should support delete user, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteUser(users[0]) @@ -352,6 +393,7 @@ describe('SettingsComponent', () => { }) it('should logout current user if password changed, after delay', fakeAsync(() => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editUser(users[0]) @@ -371,6 +413,7 @@ describe('SettingsComponent', () => { })) it('should support edit / create group, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editGroup(groups[0]) @@ -386,6 +429,7 @@ describe('SettingsComponent', () => { }) it('should support delete group, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteGroup(users[0]) @@ -406,12 +450,71 @@ describe('SettingsComponent', () => { }) it('should get group name', () => { + completeSetup() component.maybeInitializeTab(5) // UsersGroups expect(component.getGroupName(1)).toEqual(groups[0].name) expect(component.getGroupName(11)).toEqual('') }) + it('should show errors on load if load mailAccounts failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(mailAccountService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load mail accounts')) + ) + completeSetup(mailAccountService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load mailRules failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(mailRuleService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load mail rules')) + ) + completeSetup(mailRuleService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[3].nativeElement.dispatchEvent(new MouseEvent('click')) // mail tab + fixture.detectChanges() + // tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load users failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(userService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load users')) + ) + completeSetup(userService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + + it('should show errors on load if load groups failure', () => { + const toastErrorSpy = jest.spyOn(toastService, 'showError') + jest + .spyOn(groupService, 'listAll') + .mockImplementation(() => + throwError(() => new Error('failed to load groups')) + ) + completeSetup(groupService) + const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) + tabButtons[4].nativeElement.dispatchEvent(new MouseEvent('click')) // users tab + fixture.detectChanges() + expect(toastErrorSpy).toBeCalled() + }) + it('should support edit / create mail account, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailAccount(mailAccounts[0] as PaperlessMailAccount) @@ -427,6 +530,7 @@ describe('SettingsComponent', () => { }) it('should support delete mail account, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailAccount(mailAccounts[0] as PaperlessMailAccount) @@ -447,6 +551,7 @@ describe('SettingsComponent', () => { }) it('should support edit / create mail rule, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.editMailRule(mailRules[0] as PaperlessMailRule) @@ -462,6 +567,7 @@ describe('SettingsComponent', () => { }) it('should support delete mail rule, show error if needed', () => { + completeSetup() let modal: NgbModalRef modalService.activeInstances.subscribe((refs) => (modal = refs[0])) component.deleteMailRule(mailRules[0] as PaperlessMailRule) diff --git a/src-ui/src/app/components/manage/settings/settings.component.ts b/src-ui/src/app/components/manage/settings/settings.component.ts index a49f2dd21..785e5d347 100644 --- a/src-ui/src/app/components/manage/settings/settings.component.ts +++ b/src-ui/src/app/components/manage/settings/settings.component.ts @@ -146,7 +146,7 @@ export class SettingsComponent private groupsService: GroupService, private router: Router, private modalService: NgbModal, - private permissionsService: PermissionsService + public permissionsService: PermissionsService ) { super() this.settings.settingsSaved.subscribe(() => { @@ -259,25 +259,73 @@ export class SettingsComponent navID == SettingsNavIDs.UsersGroups && (!this.users || !this.groups) ) { - this.usersService.listAll().subscribe((r) => { - this.users = r.results - this.groupsService.listAll().subscribe((r) => { - this.groups = r.results - this.initialize(false) + this.usersService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.users = r.results + this.groupsService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.groups = r.results + this.initialize(false) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving groups`, + 10000, + JSON.stringify(e) + ) + }, + }) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving users`, + 10000, + JSON.stringify(e) + ) + }, }) - }) } else if ( navID == SettingsNavIDs.Mail && (!this.mailAccounts || !this.mailRules) ) { - this.mailAccountService.listAll().subscribe((r) => { - this.mailAccounts = r.results + this.mailAccountService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.mailAccounts = r.results - this.mailRuleService.listAll().subscribe((r) => { - this.mailRules = r.results - this.initialize(false) + this.mailRuleService + .listAll() + .pipe(first()) + .subscribe({ + next: (r) => { + this.mailRules = r.results + this.initialize(false) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving mail rules`, + 10000, + JSON.stringify(e) + ) + }, + }) + }, + error: (e) => { + this.toastService.showError( + $localize`Error retrieving mail accounts`, + 10000, + JSON.stringify(e) + ) + }, }) - }) } } diff --git a/src-ui/src/app/services/toast.service.ts b/src-ui/src/app/services/toast.service.ts index 2d11d663e..ef282c522 100644 --- a/src-ui/src/app/services/toast.service.ts +++ b/src-ui/src/app/services/toast.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core' -import { Subject, zip } from 'rxjs' +import { Subject } from 'rxjs' export interface Toast { title: string diff --git a/src-ui/src/environments/environment.prod.ts b/src-ui/src/environments/environment.prod.ts index a36d733c3..3d1d968bf 100644 --- a/src-ui/src/environments/environment.prod.ts +++ b/src-ui/src/environments/environment.prod.ts @@ -5,7 +5,7 @@ export const environment = { apiBaseUrl: document.baseURI + 'api/', apiVersion: '3', appTitle: 'Paperless-ngx', - version: '1.17.1', + version: '1.17.1-dev', webSocketHost: window.location.host, webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:', webSocketBaseUrl: base_url.pathname + 'ws/', diff --git a/src-ui/src/theme.scss b/src-ui/src/theme.scss index d90afa6c1..33748c81b 100644 --- a/src-ui/src/theme.scss +++ b/src-ui/src/theme.scss @@ -80,6 +80,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml, MailBox: """ Returns the correct MailBox instance for the given configuration. """ + ssl_context = ssl.create_default_context() + if settings.EMAIL_CERTIFICATE_FILE is not None: # pragma: nocover + ssl_context.load_verify_locations(cafile=settings.EMAIL_CERTIFICATE_FILE) + if security == MailAccount.ImapSecurity.NONE: mailbox = MailBoxUnencrypted(server, port) elif security == MailAccount.ImapSecurity.STARTTLS: - mailbox = MailBoxTls(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBoxTls(server, port, ssl_context=ssl_context) elif security == MailAccount.ImapSecurity.SSL: - mailbox = MailBox(server, port, ssl_context=ssl.create_default_context()) + mailbox = MailBox(server, port, ssl_context=ssl_context) else: raise NotImplementedError("Unknown IMAP security") # pragma: nocover return mailbox diff --git a/src/paperless_mail/parsers.py b/src/paperless_mail/parsers.py index 4365d21a4..da9259a69 100644 --- a/src/paperless_mail/parsers.py +++ b/src/paperless_mail/parsers.py @@ -215,7 +215,11 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post(url_merge, files=pdf_collection, timeout=30.0) + response = httpx.post( + url_merge, + files=pdf_collection, + timeout=settings.CELERY_TASK_TIME_LIMIT, + ) response.raise_for_status() # ensure we notice bad responses archive_path.write_bytes(response.content) @@ -330,7 +334,7 @@ class MailDocumentParser(DocumentParser): files=files, headers=headers, data=data, - timeout=30.0, + timeout=settings.CELERY_TASK_TIME_LIMIT, ) response.raise_for_status() # ensure we notice bad responses except Exception as err: @@ -409,7 +413,12 @@ class MailDocumentParser(DocumentParser): file_multi_part[2], ) - response = httpx.post(url, files=files, data=data, timeout=30.0) + response = httpx.post( + url, + files=files, + data=data, + timeout=settings.CELERY_TASK_TIME_LIMIT, + ) response.raise_for_status() # ensure we notice bad responses except Exception as err: raise ParseError(f"Error while converting document to PDF: {err}") from err diff --git a/src/paperless_tika/parsers.py b/src/paperless_tika/parsers.py index b6a9dd621..402a37215 100644 --- a/src/paperless_tika/parsers.py +++ b/src/paperless_tika/parsers.py @@ -100,7 +100,7 @@ class TikaDocumentParser(DocumentParser): files=files, headers=headers, data=data, - timeout=30.0, + timeout=settings.CELERY_TASK_TIME_LIMIT, ) response.raise_for_status() # ensure we notice bad responses except Exception as err: