diff --git a/e2e/cypress/integration/compose.ts b/e2e/cypress/integration/compose.ts index 4b048ad97..af1a1372b 100644 --- a/e2e/cypress/integration/compose.ts +++ b/e2e/cypress/integration/compose.ts @@ -95,7 +95,7 @@ describe('Composing emails', () => { cy.visit('/compose'); cy.wait('@listAllmessages', {'timeout':10000}); cy.visit('/compose?new=true'); - + cy.get('button[mattooltip="Close draft"').click(); cy.location().should((loc) => { expect(loc.pathname).to.eq('/compose'); @@ -141,7 +141,7 @@ describe('Composing emails', () => { cy.location().should((loc) => { expect(loc.pathname).to.eq('/contacts/id-mr-postcode'); expect(loc.search).to.eq(''); - }); + }); }); it('should find the same address in original "To" and our "From" field in Reply', () => { @@ -152,4 +152,16 @@ describe('Composing emails', () => { cy.get('button[mattooltip="Reply"]').click(); cy.get('.mat-select-value-text span').contains(address, { matchCase: false }); }); + + it('should show a save template button and save on click', () => { + cy.visit('/compose?new=true'); + cy.get('input[data-placeholder="Subject"]').type('Template subject here'); + cy.get('button[mattooltip="Save as template"').click(); + cy.location().should((loc) => { + expect(loc.pathname).to.eq('/compose'); + expect(loc.search).to.eq('?new=true'); + }); + + cy.get('snack-bar-container').should('contain', 'Saved to templates'); + }) }); diff --git a/e2e/cypress/integration/folders.ts b/e2e/cypress/integration/folders.ts index 36f059be4..269270e01 100644 --- a/e2e/cypress/integration/folders.ts +++ b/e2e/cypress/integration/folders.ts @@ -1,6 +1,10 @@ /// <reference types="cypress" /> describe('Folder management', () => { + function canvas() { + return cy.get('canvastable canvas:first-of-type'); + } + it('should create folder at root level', () => { cy.visit('/'); @@ -26,4 +30,13 @@ describe('Folder management', () => { cy.contains('div.mat-menu-content button', 'Empty trash').click(); cy.wait('@emptyTrashReq'); }); + + it('should create new draft on templates folder message click', () => { + cy.visit('/') + cy.contains('mat-tree-node', 'Templates').click() + canvas().click({ x: 55, y: 40 }); + cy.location().should((loc) => { + expect(loc.pathname).to.eq('/compose'); + }); + }) }); diff --git a/e2e/mockserver/mockserver.ts b/e2e/mockserver/mockserver.ts index 0905fe94e..5df643199 100644 --- a/e2e/mockserver/mockserver.ts +++ b/e2e/mockserver/mockserver.ts @@ -1,18 +1,18 @@ // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2018 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 <https://www.gnu.org/licenses/>. // ---------- END RUNBOX LICENSE ---------- @@ -105,6 +105,22 @@ export class MockServer { 'subfolders': [], 'type': 'trash' }, + { + 'id': '2', + 'total': 1, + 'size': '344', + 'name': 'Templates', + 'new': 0, + 'folder': 'Templates', + 'type': 'templates', + 'old': 296, + 'msg_total': 1, + 'priority': '4', + 'subfolders': [], + 'msg_new': 0, + 'msg_size': '344', + 'parent': null + }, ]; vtimezone_oslo = @@ -355,7 +371,7 @@ END:VCALENDAR response.end(JSON.stringify(this.emailFoldersListResponse())); break; case '/mail/download_xapian_index': - response.end(''); + response.end(this.templatescontents()); break; case '/mail/download_xapian_index?inbox': response.end(this.inboxcontents()); @@ -413,6 +429,15 @@ END:VCALENDAR return trashlines.join('\n'); } + templatescontents() { + const lines = []; + for (let msg_id = 1000; msg_id < 10000; msg_id++) { + lines.push(`${msg_id} 1548071429 1547830043 Templates 1 0 0 "Template" <template@runbox.com> ` + + `Template2<template@example.org> TEMPLATE 709 n `); + } + return lines.join('\n'); + } + inboxcontents() { const inboxlines = []; for (let msg_id = 1; msg_id < 10; msg_id++) { @@ -844,7 +869,7 @@ END:VCALENDAR profiles_verified() { return { - 'results': + 'results': [{ 'smtp_username': null, 'email': 'a2@example.com', diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 99c9ddf12..7473ac6f6 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -1,18 +1,18 @@ // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2022 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 <https://www.gnu.org/licenses/>. // ---------- END RUNBOX LICENSE ---------- @@ -74,7 +74,7 @@ const LOCAL_STORAGE_SHOW_UNREAD_ONLY = 'rmm7mailViewerShowUnreadOnly'; const LOCAL_STORAGE_SHOW_POPULAR_RECIPIENTS = 'showPopularRecipients'; const LOCAL_STORAGE_INDEX_PROMPT = 'localSearchPromptDisplayed'; const TOOLBAR_LIST_BUTTON_WIDTH = 30; - + @Component({ moduleId: 'angular2/app/', // eslint-disable-next-line @angular-eslint/component-selector @@ -718,7 +718,7 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis ) - 1; } } - + public openMarkOpMenu() { this.showSelectMarkOpMenu = true; @@ -916,6 +916,17 @@ export class AppComponent implements OnInit, AfterViewInit, CanvasTableSelectLis } public rowSelected(rowIndex: number, columnIndex: number, multiSelect?: boolean) { + const isSelect = (columnIndex === 0) || multiSelect + + if ((this.selectedFolder === this.messagelistservice.templateFolderName) && !isSelect) { + this.draftDeskService.newTemplateDraft( + this.canvastable.rows.getRowMessageId(rowIndex) + ); + this.drafts(); + + return; + } + this.canvastable.rows.rowSelected(rowIndex, columnIndex, multiSelect); this.showSelectOperations = this.canvastable.rows.anySelected(); diff --git a/src/app/compose/compose.component.html b/src/app/compose/compose.component.html index a4ad062fc..de7825bb1 100644 --- a/src/app/compose/compose.component.html +++ b/src/app/compose/compose.component.html @@ -24,13 +24,16 @@ </button> <button *ngIf="!editing" mat-icon-button (click)="editDraft()" matTooltip="Edit draft" id="editDraftIcon"> <mat-icon svgIcon="pencil"></mat-icon> - </button> + </button> + <button *ngIf="editing" mat-icon-button matTooltip="Save as template" (click)="saveTemplate(true)" id="saveTemplate"> + <mat-icon svgIcon="file-document"></mat-icon> + </button> <button *ngIf="editing" mat-icon-button matTooltip="Send mail" (click)="submit(true)" id="sendMail"> <mat-icon svgIcon="send"></mat-icon> </button> </div> - </mat-card-actions> - <mat-card-subtitle> + </mat-card-actions> + <mat-card-subtitle> <mat-form-field floatPlaceholder="always" *ngIf="editing" style="width: 100%" id="fieldFrom"> <mat-select placeholder="From" formControlName="from"> <mat-option *ngFor="let from of draftDeskservice.fromsSubject.value" [value]="from.nameAndAddress"> @@ -42,7 +45,7 @@ <span *ngFor="let t of model.to"> {{t.nameAndAddress}} </span> - </span> + </span> <div class="fieldRecipient"> <mailrecipient-input *ngIf="editing" style="flex-grow: 1" [initialfocus]="model.to.length === 0" @@ -86,7 +89,7 @@ [recipients]="model.cc" (updateRecipient)="onUpdateRecipient('cc', $event)" ></mailrecipient-input> - <button mat-icon-button (click)="formGroup.controls.cc.setValue(null)"><mat-icon svgIcon="close"></mat-icon></button> + <button mat-icon-button (click)="formGroup.controls.cc.setValue(null)"><mat-icon svgIcon="close"></mat-icon></button> </div> <div style="display: flex;" *ngIf="editing && hasBCC" class="fieldRecipient" (drop)="recipientDropped($event, 'bcc')"> <mailrecipient-input *ngIf="editing" style="width: auto; flex-grow: 1" @@ -96,11 +99,11 @@ ></mailrecipient-input> <button mat-icon-button (click)="formGroup.controls.bcc.setValue(null)"><mat-icon svgIcon="close"></mat-icon></button> </div> - </mat-card-subtitle> - <mat-card-content> + </mat-card-subtitle> + <mat-card-content> <mat-form-field *ngIf="editing" floatPlaceholder="auto" id="fieldSubject"> <input matInput placeholder="Subject" name="subject" formControlName="subject" /> - </mat-form-field> + </mat-form-field> <section *ngIf="editing" [ngClass]="{'dropzonehighlight': showDropZone, 'overdropzone': draggingOverDropZone}" (dragover)="draggingOverDropZone=true" (dragleave)="draggingOverDropZone=false" (drop)="dropFiles($event)" id="dropZone"> <h1 *ngIf="showDropZone" id="dropZoneText">Drop files here</h1> <ng-container *ngIf="uploadProgress | async as uprogress"> @@ -127,11 +130,11 @@ <h1 *ngIf="showDropZone" id="dropZoneText">Drop files here</h1> <button mat-icon-button id="deleteAttachment" (click)="removeAttachment(i)"><mat-icon svgIcon="delete"></mat-icon></button> </ng-container> </div> - </section> + </section> <span [hidden]="editing"> {{this.model.preview}} </span> - + <div [hidden]="!editing"> <div class="draft-buttons"> <button *ngIf="editing" mat-icon-button matTooltip="Attach files" id="attachMobile" (click)="attachFilesClicked()"> @@ -142,21 +145,21 @@ <h1 *ngIf="showDropZone" id="dropZoneText">Drop files here</h1> <textarea style=" width: 100%; height: 300px; - " + " [id]="selector" [hidden]="!formGroup.value.useHTML"> </textarea> <mat-form-field id="messageTextArea" *ngIf="!formGroup.value.useHTML" floatPlaceholder="auto"> - <textarea + <textarea #messageTextArea - placeholder="Message text" - matInput + placeholder="Message text" + matInput formControlName="msg_body" rows="20" > </textarea> - </mat-form-field> - </div> + </mat-form-field> + </div> </mat-card-content> </mat-card> diff --git a/src/app/compose/compose.component.ts b/src/app/compose/compose.component.ts index c0a5d4c94..1e46cefe0 100644 --- a/src/app/compose/compose.component.ts +++ b/src/app/compose/compose.component.ts @@ -1,18 +1,18 @@ // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2022 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 <https://www.gnu.org/licenses/>. // ---------- END RUNBOX LICENSE ---------- @@ -77,6 +77,7 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { public uploadRequest: Subscription = null; public saved: Date = null; public tinymce_plugin: TinyMCEPlugin; + public isTemplate: boolean = false; finishImageUpload: AsyncSubject<any> = null; uploadProgress: BehaviorSubject<number> = new BehaviorSubject(-1); has_pasted_signature: boolean; @@ -207,7 +208,15 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { 'replying', ]); })) - .subscribe(() => this.submit(false)); + .subscribe(() => { + // Disable auto-save when in template edit mode. This prevents + // creating a draft while the user might be editing the template. + // The side-effect is that a draft created from a template won't + // have autosave until it was saved as a draft. + if (this.model.tid) return; + + this.submit(false) + }); this.formGroup.controls.from.valueChanges .pipe(debounceTime(1000)) @@ -460,6 +469,11 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { } public loadDraft(msgObj) { + if (msgObj.errors) { + this.snackBar.open(msgObj.errors[0], 'Ok') + throw msgObj + } + const model = new DraftFormModel(); model.mid = typeof msgObj.mid === 'string' ? parseInt(msgObj.mid, 10) : msgObj.mid; this.draftDeskservice.isEditing = model.mid; @@ -673,10 +687,14 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { } public submit(send: boolean = false) { + const isTemplate = Boolean(this.isTemplate ?? this.model.tid); + if (this.savingInProgress) { return; } + this.savingInProgress = true; + if (send) { let validemails = false; validemails = isValidEmailArray(this.model.to); @@ -780,7 +798,14 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { } else { this.rmmapi.me.pipe(mergeMap((me) => { return this.http.post('/rest/v1/draft', { - type: 'draft', + ...(isTemplate ? { + type: 'template', + mid: this.model.tid ?? this.model.mid, + tid: this.model.tid ?? this.model.mid, + } : { + type: 'draft', + mid: this.model.mid + }), username: me.username, from: from && from.id ? from.from_name + '%' + from.email + '%' + from.id : from ? from.email : undefined, from_email: from ? from.email : '', @@ -795,7 +820,6 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { tags: [], ctype: this.model.useHTML ? 'html' : null, save: send ? 'Send' : 'Save', - mid: this.model.mid, attachments: this.model.attachments ? this.model.attachments .filter((att) => att.file !== 'UTF-8Q') @@ -805,6 +829,10 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { }); } )).subscribe((res: any) => { + if (this.isTemplate) { + this.snackBar.open('Saved to templates', 'Ok', { duration: 3000 }); + } + if (res.mid) { const newMid = typeof res.mid === 'string' ? parseInt(res.mid, 10) : res.mid; if (this.model.isUnsaved()) { @@ -839,6 +867,11 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { } } + public saveTemplate() { + this.isTemplate = true + this.submit(false); + } + ngOnDestroy() { if (this.editor) { this.tinymce_plugin.remove(this.editor); diff --git a/src/app/compose/draftdesk.service.ts b/src/app/compose/draftdesk.service.ts index d0bcede21..c841094d7 100644 --- a/src/app/compose/draftdesk.service.ts +++ b/src/app/compose/draftdesk.service.ts @@ -1,18 +1,18 @@ // --------- BEGIN RUNBOX LICENSE --------- // Copyright (C) 2016-2022 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 <https://www.gnu.org/licenses/>. // ---------- END RUNBOX LICENSE ---------- @@ -49,6 +49,7 @@ export class DraftFormModel { from: string = null; mid: number = (DraftFormModel.newDraftCount--); + tid: number = null; to: MailAddressInfo[] = []; cc: MailAddressInfo[] = []; bcc: MailAddressInfo[] = []; @@ -131,7 +132,7 @@ export class DraftFormModel { const localTZ = moment.tz.guess(); const replyHeaderHTML = 'On ' + moment(mailObj.date, localTZ).format('yyyy-MM-DD HH:mm Z') - + ' ' + moment.tz(localTZ).format('z') + + ' ' + moment.tz(localTZ).format('z') + ', ' + (mailObj.from[0].name ? `"${mailObj.from[0].name}" <${mailObj.from[0].address}> wrote:` @@ -157,7 +158,7 @@ export class DraftFormModel { } public static trimmedPreview(preview: string): string { - let ret = preview.substring(0, DraftFormModel.MAX_DRAFT_PREVIEW_LENGTH); + let ret = (preview ?? '').substring(0, DraftFormModel.MAX_DRAFT_PREVIEW_LENGTH); if (ret.length === DraftFormModel.MAX_DRAFT_PREVIEW_LENGTH) { ret += '...'; } @@ -309,6 +310,34 @@ export class DraftDeskService { this.draftModels.next(models); } + public async newTemplateDraft( + messageId: number, + ) { + + this.rmmapi.getMessageContents(messageId).subscribe((contents) => { + const res: any = Object.assign({}, contents); + const {subject} = res.headers + let { to } = res.headers + + if (to) { + to = new MailAddressInfo(to.value.name, to.value.address).nameAndAddress; + } + + const draftFormModel = DraftFormModel.create( + -1, + this.mainIdentity(), + to, + subject + ) + + draftFormModel.tid = messageId; + draftFormModel.msg_body = contents.text.text; + draftFormModel.html = contents.text.html; + + return this.newDraft(draftFormModel); + }) + } + public async newBugReport( local_search: boolean, keep_pane: boolean, diff --git a/src/app/rmmapi/messagelist.service.ts b/src/app/rmmapi/messagelist.service.ts index 75f8f589a..8a29406ce 100644 --- a/src/app/rmmapi/messagelist.service.ts +++ b/src/app/rmmapi/messagelist.service.ts @@ -62,6 +62,7 @@ export class MessageListService { trashFolderName = 'Trash'; spamFolderName = 'Spam'; + templateFolderName = 'Templates'; ignoreUnreadInFolders = [ 'Sent' ];