diff --git a/e2e/mockserver/mockserver.ts b/e2e/mockserver/mockserver.ts index 3285ec46b..6a760bed7 100644 --- a/e2e/mockserver/mockserver.ts +++ b/e2e/mockserver/mockserver.ts @@ -269,6 +269,9 @@ export class MockServer { case '/rest/v1/me': response.end(JSON.stringify(this.me())); break; + case '/rest/v1/filter': + response.end(JSON.stringify(this.filters())); + break; case '/rest/v1/list/deleted_messages': response.end(JSON.stringify({ 'message_ids': [], 'status': 'success' })); break; @@ -691,4 +694,74 @@ export class MockServer { ], ]; } + + filters(): any { + return { + "result": { + "filters": [ + { + "active": true, + "action": "t", + "id": 101486365, + "priority": 0, + "location": "1", + "target": "Inbox", + "negated": false, + "string": "from-rule" + }, + { + "string": "reply-to-rule", + "active": true, + "action": "t", + "id": 101486367, + "priority": 0, + "location": "4", + "target": "Inbox", + "negated": false + }, + { + "negated": false, + "target": "Inbox", + "location": "0", + "priority": 0, + "id": 101486369, + "action": "t", + "active": true, + "string": "to-rule" + }, + { + "string": "from-forwarded", + "negated": false, + "priority": 0, + "location": "1", + "target": "target@runbox.com", + "action": "f", + "id": 101486371, + "active": true + }, + { + "string": "cc-redirected", + "active": true, + "action": "b", + "id": 101486373, + "priority": 0, + "location": "3", + "target": "target@runbox.com", + "negated": false + }, + { + "string": "added-in-rmm7", + "active": true, + "action": "t", + "id": 101486379, + "location": "0", + "priority": null, + "target": "Inbox", + "negated": false + } + ] + }, + "status": "success" + } + } } diff --git a/src/app/account-app/account-app.module.ts b/src/app/account-app/account-app.module.ts index 513ed9c9d..a14063039 100644 --- a/src/app/account-app/account-app.module.ts +++ b/src/app/account-app/account-app.module.ts @@ -31,6 +31,9 @@ import { HeaderToolbarComponent } from '../menu/headertoolbar.component'; import { AccountAppComponent } from './account-app.component'; import { AccountAddonsComponent } from './account-addons.component'; import { AccountComponentsComponent } from './account-components.component'; +import { AccountFiltersComponent } from './filters/account-filters.component'; +import { FilterEditorComponent } from './filters/filter-editor.component'; +import { SenderListComponent } from './filters/sender-list.component'; import { AccountRenewalsComponent } from './account-renewals.component'; import { AccountReceiptComponent } from './account-receipt.component'; import { AccountTransactionsComponent } from './account-transactions.component'; @@ -63,6 +66,7 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatRadioModule } from '@angular/material/radio'; import { MatSelectModule } from '@angular/material/select'; import { MatSidenavModule } from '@angular/material/sidenav'; +import { MatTabsModule } from '@angular/material/tabs'; import { MatTableModule } from '@angular/material/table'; import { MatToolbarModule } from '@angular/material/toolbar'; import { MatTooltipModule } from '@angular/material/tooltip'; @@ -74,6 +78,7 @@ import { SubAccountRenewalDialogComponent } from './sub-account-renewal-dialog'; AccountAddonsComponent, AccountAppComponent, AccountComponentsComponent, + AccountFiltersComponent, AccountReceiptComponent, AccountRenewalsComponent, AccountTransactionsComponent, @@ -90,6 +95,8 @@ import { SubAccountRenewalDialogComponent } from './sub-account-renewal-dialog'; SubAccountRenewalDialogComponent, RunboxTimerComponent, CreditCardsComponent, + FilterEditorComponent, + SenderListComponent, ], imports: [ BrowserAnimationsModule, @@ -111,6 +118,7 @@ import { SubAccountRenewalDialogComponent } from './sub-account-renewal-dialog'; MatRadioModule, MatSelectModule, MatSidenavModule, + MatTabsModule, MatTableModule, MatToolbarModule, MatTooltipModule, @@ -173,6 +181,10 @@ import { SubAccountRenewalDialogComponent } from './sub-account-renewal-dialog'; path: 'credit_cards', component: CreditCardsComponent }, + { + path: 'filters', + component: AccountFiltersComponent + }, ] } ] diff --git a/src/app/account-app/filters/account-filters.component.html b/src/app/account-app/filters/account-filters.component.html new file mode 100644 index 000000000..bad3961ac --- /dev/null +++ b/src/app/account-app/filters/account-filters.component.html @@ -0,0 +1,53 @@ +

Filters

+ + + +
+
+ +
+ + + +

+ Showing {{ filters.length }}/{{ filtersTotal }} filters + + +

+
+
+ +
+ +
+
+ +
+ +
+
+
+ + + + diff --git a/src/app/account-app/filters/account-filters.component.ts b/src/app/account-app/filters/account-filters.component.ts new file mode 100644 index 000000000..780505414 --- /dev/null +++ b/src/app/account-app/filters/account-filters.component.ts @@ -0,0 +1,240 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2020 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, QueryList, ViewChildren } from '@angular/core'; +import { ReplaySubject, Subject } from 'rxjs'; +import { MatSnackBar } from '@angular/material/snack-bar'; + +import { Filter, RunboxWebmailAPI, FilteredSender } from '../../rmmapi/rbwebmail'; +import { take, debounceTime } from 'rxjs/operators'; +import { FilterEditorComponent } from './filter-editor.component'; + +@Component({ + selector: 'app-account-filters-component', + templateUrl: './account-filters.component.html', +}) +export class AccountFiltersComponent { + @ViewChildren(FilterEditorComponent) filterComponents: QueryList; + filters: ReplaySubject = new ReplaySubject(1); + shownFilters: Subject = new Subject(); + blockedSenders: ReplaySubject = new ReplaySubject(1); + allowedSenders: ReplaySubject = new ReplaySubject(1); + + filtersReordered: Subject = new Subject(); + + filterPageSize = 50; + filtersShown = this.filterPageSize; + filtersTotal: number; + + constructor( + private rmmapi: RunboxWebmailAPI, + private snackbar: MatSnackBar, + ) { + this.rmmapi.getFilters().subscribe(filters => { + this.filters.next(filters.filters); + this.allowedSenders.next(filters.allowed); + this.blockedSenders.next(filters.blocked); + }); + + this.filters.subscribe(_ => this.updateShownFilters()); + + this.filtersReordered.pipe(debounceTime(1500)).subscribe(() => { + this.filters.pipe(take(1)).subscribe(filters => { + const order = filters.map(f => f.id); + this.rmmapi.reorderFilters(order).subscribe(() => { + console.log('Filters reordered'); + }); + }); + }); + } + + newFilter(): void { + const template = { + id: null, + str: '', + action: 't', + active: true, + target: 'Inbox', + negated: false, + location: '0', + priority: -1, + }; + this.updateFilters( + filters => [template, ...filters] + ); + } + + deleteFilter(target: Filter): void { + if (target.id) { + console.log(`Deleting filter #${target.id}`); + this.rmmapi.deleteFilter(target.id).subscribe( + () => console.log(`Filter #${target.id} deleted`), + ); + } + this.updateFilters( + filters => filters.filter(f => f !== target) + ); + } + + saveFilter(existing: Filter, replacement: Filter): void { + console.log(`Uploading filter to server ${JSON.stringify(replacement)}`); + this.rmmapi.saveFilter(replacement).subscribe( + id => { + replacement.id = id; // only needed when a new one is created, but no difference to us + this.updateFilters( + filters => filters.map(f => { + if (f === existing) { + return replacement; + } else { + return f; + } + }) + ); + }, + _err => this.showError(`Error ${existing.id ? 'updating' : 'creating'} filter.`), + ); + } + + updateFilters(transform: (_: Filter[]) => Filter[]): void { + this.filters.pipe(take(1)).subscribe( + filters => this.filters.next(transform(filters)) + ); + } + + moveFilterUp(filter: Filter): void { + this.updateFilters(filters => { + const index = filters.findIndex(f => f === filter); + if (index === 0) { + return filters; + } + const head = filters.slice(0, index); + let tail = filters.slice(index + 1); + tail = [head.pop(), ...tail]; + setTimeout(() => this.hilightFilter(filter), 50); + return [...head, filter, ...tail]; + }); + this.filtersReordered.next(); + } + + moveFilterDown(filter: Filter): void { + this.updateFilters(filters => { + const index = filters.findIndex(f => f === filter); + if (index === filters.length - 1) { + return filters; + } + const head = filters.slice(0, index); + const tail = filters.slice(index + 1); + setTimeout(() => this.hilightFilter(filter), 50); + return [...head, tail.shift(), filter, ...tail]; + }); + this.filtersReordered.next(); + } + + hilightFilter(filter: Filter): void { + this.filterComponents.find(fc => fc.filter === filter).hilight(); + } + + showAllFilters(): void { + this.filtersShown = Number.MAX_SAFE_INTEGER; + this.updateShownFilters(); + } + + showMoreFilters(): void { + this.filtersShown += this.filterPageSize; + this.updateShownFilters(); + } + + updateShownFilters(): void { + this.filters.pipe(take(1)).subscribe(filters => { + this.shownFilters.next(filters.slice(0, this.filtersShown)); + this.filtersTotal = filters.length; + }); + } + + addAllowed(address: string): void { + this.rmmapi.whitelistSender(address).subscribe( + () => { + this.allowedSenders.pipe(take(1)).subscribe(allowed => { + this.allowedSenders.next( + allowed.concat({ + id: address, + address: address, + }) + ); + }); + }, + _err => this.showError('Error whitelisting an address.'), + ); + } + + removeAllowed(id: any): void { + this.rmmapi.dewhitelistSender(id).subscribe( + () => { + this.allowedSenders.pipe(take(1)).subscribe(allowed => { + this.allowedSenders.next( + allowed.filter(s => s.id !== id) + ); + }); + }, + _err => this.showError('Error dewhitelisting an address.'), + ); + } + + addBlocked(address: string): void { + const filter = { + id: null, + str: address, + action: 'k', + active: true, + target: '', + negated: false, + location: '1', + priority: -2, + }; + this.rmmapi.saveFilter(filter).subscribe( + id => { + this.blockedSenders.pipe(take(1)).subscribe(blocked => { + this.blockedSenders.next( + blocked.concat({ id, address }) + ); + }); + }, + _err => this.showError('Error blocking an address.'), + ); + } + + removeBlocked(id: any): void { + this.rmmapi.deleteFilter(id).subscribe( + () => { + this.blockedSenders.pipe(take(1)).subscribe(blocked => { + this.blockedSenders.next( + blocked.filter(f => f.id !== id) + ); + }); + }, + _err => this.showError('Error unblocking an address.'), + ); + } + + showError(message: string): void { + this.snackbar.open(message + ' Try again or contact Runbox Support', 'Ok', { + duration: 3000, + }); + } +} diff --git a/src/app/account-app/filters/filter-editor.component.html b/src/app/account-app/filters/filter-editor.component.html new file mode 100644 index 000000000..51c75359b --- /dev/null +++ b/src/app/account-app/filters/filter-editor.component.html @@ -0,0 +1,86 @@ + + + +
+
+ Active + + +
+ + A message where + + To + From + Subject + Cc + Reply-To + Body + Return-Path + Delivered-To + Mailing-List + Header + Address-suffix + List-ID + + +
+ + + doesn't contain + contains + + + + +
+ + Will be + + moved to folder + forwarded to + redirected to + + + + Select folder + + {{ folder.name }} + + + + + Target + + + +
+
+ + + + + + +
diff --git a/src/app/account-app/filters/filter-editor.component.ts b/src/app/account-app/filters/filter-editor.component.ts new file mode 100644 index 000000000..a604d14fb --- /dev/null +++ b/src/app/account-app/filters/filter-editor.component.ts @@ -0,0 +1,97 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2019 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, Input, OnInit, Output, EventEmitter, ViewChild, ElementRef, Renderer2 } from '@angular/core'; +import { Filter } from '../../rmmapi/rbwebmail'; +import { MessageListService } from '../../rmmapi/messagelist.service'; +import { FormGroup, FormBuilder } from '@angular/forms'; + +@Component({ + selector: 'app-account-filter-editor', + templateUrl: './filter-editor.component.html', +}) +export class FilterEditorComponent implements OnInit { + @ViewChild('cardComponent', { read: ElementRef }) cardComponent: ElementRef; + @Input() filter: Filter; + + @Output() delete: EventEmitter = new EventEmitter(); + @Output() save: EventEmitter = new EventEmitter(); + @Output() moveUp: EventEmitter = new EventEmitter(); + @Output() moveDown: EventEmitter = new EventEmitter(); + + isNegated: boolean; + folders: { + key: string, + name: string, + }[] = []; + form: FormGroup; + + constructor( + private fb: FormBuilder, + private renderer: Renderer2, + messageListService: MessageListService, + ) { + messageListService.folderListSubject.subscribe(folders => { + this.folders = folders.map(f => { + return { + key: f.folderPath.replace('/', '.'), + name: f.folderPath, + }; + }); + }); + } + + negate(): void { + this.isNegated = !this.isNegated; + this.form.get('negated').setValue(this.isNegated); + this.form.get('negated').markAsDirty(); + } + + ngOnInit() { + this.reloadForm(); + } + + reloadForm(): void { + this.form = this.fb.group({ + active: this.fb.control(this.filter.active), + location: this.fb.control(this.filter.location), + negated: this.fb.control(this.filter.negated), + str: this.fb.control(this.filter.str), + action: this.fb.control(this.filter.action), + target: this.fb.control(this.filter.target), + }); + this.isNegated = this.filter.negated; + } + + deleteFilter(): void { + this.delete.emit(); + } + + saveFilter(): void { + const newFilter = {id: this.filter.id}; + Object.assign(newFilter, this.form.value); + this.save.emit(newFilter as Filter); + } + + hilight(): void { + this.renderer.addClass(this.cardComponent.nativeElement, 'hilight'); + this.cardComponent.nativeElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + setTimeout(() => this.renderer.removeClass(this.cardComponent.nativeElement, 'hilight'), 250); + } +} diff --git a/src/app/account-app/filters/sender-list.component.html b/src/app/account-app/filters/sender-list.component.html new file mode 100644 index 000000000..b85c5805f --- /dev/null +++ b/src/app/account-app/filters/sender-list.component.html @@ -0,0 +1,36 @@ + + + + {{ sender.address }} + + + + Add new + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/account-app/filters/sender-list.component.ts b/src/app/account-app/filters/sender-list.component.ts new file mode 100644 index 000000000..5334a3f59 --- /dev/null +++ b/src/app/account-app/filters/sender-list.component.ts @@ -0,0 +1,43 @@ +// --------- BEGIN RUNBOX LICENSE --------- +// Copyright (C) 2016-2020 Runbox Solutions AS (runbox.com). +// +// This file is part of Runbox 7. +// +// Runbox 7 is free software: You can redistribute it and/or modify it +// under the terms of the GNU General Public License as published by the +// Free Software Foundation, either version 3 of the License, or (at your +// option) any later version. +// +// Runbox 7 is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Runbox 7. If not, see . +// ---------- END RUNBOX LICENSE ---------- + +import { Component, Input, OnChanges, Output, EventEmitter } from '@angular/core'; + +import { FilteredSender } from '../../rmmapi/rbwebmail'; + +@Component({ + selector: 'app-account-sender-list', + templateUrl: './sender-list.component.html', +}) +export class SenderListComponent implements OnChanges { + @Input() senders: FilteredSender[]; + + @Output() add: EventEmitter = new EventEmitter(); + @Output() remove: EventEmitter = new EventEmitter(); + + senderList: FilteredSender[]; + + ngOnChanges() { + // adding a placeholder for the input field + this.senderList = this.senders.concat({ + id: null, + address: '', + }); + } +} diff --git a/src/app/menu/headertoolbar.component.html b/src/app/menu/headertoolbar.component.html index 25763ec76..7007f6941 100644 --- a/src/app/menu/headertoolbar.component.html +++ b/src/app/menu/headertoolbar.component.html @@ -18,7 +18,7 @@ Manager
Retrieve
- Filter
+ Filter
Access