From d6c66e5ec8415bf6d47a99002f40c3121913814c Mon Sep 17 00:00:00 2001 From: kei Date: Thu, 11 Jun 2026 06:10:52 +0200 Subject: [PATCH] feat(mail): add send again action --- src/app/compose/compose.component.ts | 2 +- src/app/compose/draftdesk.service.spec.ts | 63 ++++++++++++++++++- src/app/compose/draftdesk.service.ts | 41 ++++++++++++ src/app/mailviewer/messageactions.ts | 1 + src/app/mailviewer/rmm6messageactions.ts | 4 ++ src/app/mailviewer/rmm7messageactions.ts | 10 +++ .../singlemailviewer.component.html | 37 ++++++----- .../singlemailviewer.component.spec.ts | 20 +++++- .../mailviewer/singlemailviewer.component.ts | 9 +++ 9 files changed, 168 insertions(+), 19 deletions(-) diff --git a/src/app/compose/compose.component.ts b/src/app/compose/compose.component.ts index e1aa9ecf2..1a669452b 100644 --- a/src/app/compose/compose.component.ts +++ b/src/app/compose/compose.component.ts @@ -138,7 +138,7 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { this.draftDeskservice.fromsSubject.value[0].nameAndAddress : ''; } else { this.model.from = from.nameAndAddress; - if ( !this.has_pasted_signature && from.signature ) { + if ( !this.model.skipSignature && !this.has_pasted_signature && from.signature ) { this.has_pasted_signature = true; this.signature = from.signature; // Sig is HTML, or Reply/Fwd Draft is HTML (contains .html) diff --git a/src/app/compose/draftdesk.service.spec.ts b/src/app/compose/draftdesk.service.spec.ts index 0b4f6bbff..daa0b26ec 100644 --- a/src/app/compose/draftdesk.service.spec.ts +++ b/src/app/compose/draftdesk.service.spec.ts @@ -17,7 +17,7 @@ // along with Runbox 7. If not, see . // ---------- END RUNBOX LICENSE ---------- -import { DraftFormModel, DraftDeskService } from './draftdesk.service'; +import { DraftFormModel, DraftDeskService, ForwardedAttachment } from './draftdesk.service'; import { Identity } from '../profiles/profile.service'; import { MailAddressInfo } from '../common/mailaddressinfo'; import { RunboxWebmailAPI } from '../rmmapi/rbwebmail'; @@ -126,6 +126,67 @@ Subject: Test subject
expect(draft.isUnsaved()).toBe(true); done(); }); + it('Send again: copies a sent HTML message into a new draft', (done) => { + const draft = DraftFormModel.sendAgain({ + mid: 123, + from: [ + {address: 'from@runbox.com', name: 'From'} + ], + to: [ + {address: 'to@runbox.com', name: 'To'} + ], + cc: [ + {address: 'cc@runbox.com', name: 'CC'} + ], + bcc: [ + {address: 'bcc@runbox.com', name: 'BCC'} + ], + attachments: [ + {cid: 'inline-image'} + ], + subject: 'Test subject', + rawtext: 'blabla\nabcde', + sanitized_html: '

blabla

abcde

' + }, + [ + Identity.fromObject({'email':'fallback@runbox.com'}), + Identity.fromObject({'email':'from@runbox.com'}) + ]); + + expect(draft.subject).toBe('Test subject'); + expect(draft.from).toBe('from@runbox.com'); + expect(draft.to[0].nameAndAddress).toBe('"To" '); + expect(draft.cc[0].nameAndAddress).toBe('"CC" '); + expect(draft.bcc[0].nameAndAddress).toBe('"BCC" '); + expect(draft.msg_body).toBe('blabla\nabcde'); + expect(draft.html).toBe('

blabla

abcde

'); + expect(draft.useHTML).toBe(true); + expect(draft.skipSignature).toBe(true); + expect(draft.attachments[0] instanceof ForwardedAttachment).toBeTrue(); + expect((draft.attachments[0] as ForwardedAttachment).messageId).toBe('123'); + expect(draft.isUnsaved()).toBe(true); + done(); + }); + it('Send again: falls back to the default identity for a non-matching sender', (done) => { + const draft = DraftFormModel.sendAgain({ + from: [ + {address: 'old-address@runbox.com', name: 'Old'} + ], + to: null, + attachments: [], + subject: 'Plain subject', + rawtext: 'plain body' + }, + [ Identity.fromObject({'email':'default@runbox.com'}) ]); + + expect(draft.subject).toBe('Plain subject'); + expect(draft.from).toBe('default@runbox.com'); + expect(draft.to).toEqual([]); + expect(draft.msg_body).toBe('plain body'); + expect(draft.useHTML).toBe(false); + expect(draft.isUnsaved()).toBe(true); + done(); + }); it('Reply: Address with object, single reply-to', (done) => { console.log('Reply test: Address with object, single reply-to'); // fromObj, identities, all (t/f), html (t/f) diff --git a/src/app/compose/draftdesk.service.ts b/src/app/compose/draftdesk.service.ts index 6f1efa0be..ede33a083 100644 --- a/src/app/compose/draftdesk.service.ts +++ b/src/app/compose/draftdesk.service.ts @@ -61,6 +61,7 @@ export class DraftFormModel { in_reply_to: string; reply_to_id: string = null; replying = false; + skipSignature = false; tags: string; useHTML = false; save = 'Save'; @@ -209,6 +210,40 @@ ${mailObj.sanitized_html}`; return ret; } + public static sendAgain(mailObj, froms: Identity[]): DraftFormModel { + const ret = new DraftFormModel(); + + if (froms.length > 0) { + const originalFrom = mailObj.from && mailObj.from[0] && mailObj.from[0].address + ? mailObj.from[0].address.toLowerCase() + : null; + ret.from = ( + froms.find(fromObj => fromObj.email.toLowerCase() === originalFrom) || froms[0] + ).email; + } else { + console.error('DraftDesk: No froms passed to sendAgain'); + } + + ret.subject = mailObj.subject; + ret.skipSignature = true; + ret.to = DraftFormModel.copyAddresses(mailObj.to); + ret.cc = DraftFormModel.copyAddresses(mailObj.cc); + ret.bcc = DraftFormModel.copyAddresses(mailObj.bcc); + ret.msg_body = mailObj.rawtext || (mailObj.text && mailObj.text.text) || ''; + ret.preview = DraftFormModel.trimmedPreview(ret.msg_body); + + if (mailObj.sanitized_html) { + ret.html = mailObj.sanitized_html; + ret.useHTML = true; + } + + ret.attachments = (mailObj.attachments || []).map((attachment, ndx) => + new ForwardedAttachment(String(mailObj.mid), ndx, attachment.cid) + ); + + return ret; + } + public isUnsaved(): boolean { if (this.mid <= -1) { return true; @@ -216,6 +251,12 @@ ${mailObj.sanitized_html}`; return false; } + private static copyAddresses(addresses): MailAddressInfo[] { + return Array.isArray(addresses) + ? addresses.map((addr) => new MailAddressInfo(addr.name, addr.address)) + : []; + } + private setFromForResponse(mailObj, froms: Identity[]): void { if (froms.length > 0) { this.from = ( diff --git a/src/app/mailviewer/messageactions.ts b/src/app/mailviewer/messageactions.ts index a8bdab9dc..d85e7f452 100644 --- a/src/app/mailviewer/messageactions.ts +++ b/src/app/mailviewer/messageactions.ts @@ -29,6 +29,7 @@ export interface MessageActions { reply(useHTML?: boolean); replyToAll(useHTML?: boolean); forward(useHTML?: boolean); + sendAgain(); markSeen(seen_flag_value?: number); trainSpam(params: any); blockSender(param: string) diff --git a/src/app/mailviewer/rmm6messageactions.ts b/src/app/mailviewer/rmm6messageactions.ts index fe6ae798c..16aa84cdf 100644 --- a/src/app/mailviewer/rmm6messageactions.ts +++ b/src/app/mailviewer/rmm6messageactions.ts @@ -57,6 +57,10 @@ export class RMM6MessageActions implements MessageActions { this.openCompose('/mail/forward?message=' + this.mailViewerComponent.messageId); } + sendAgain() { + this.snackBar.open('Not supported in RMM6 yet', null, {duration: 1000}); + } + markSeen(seen_flag_value = 1) { this.http.put('/rest/v1/email/' + this.mailViewerComponent.messageId, JSON.stringify( { seen_flag : seen_flag_value })) diff --git a/src/app/mailviewer/rmm7messageactions.ts b/src/app/mailviewer/rmm7messageactions.ts index 1da73102d..ccf1f6a7e 100644 --- a/src/app/mailviewer/rmm7messageactions.ts +++ b/src/app/mailviewer/rmm7messageactions.ts @@ -135,6 +135,16 @@ export class RMM7MessageActions implements MessageActions { }); } + public sendAgain() { + ProgressDialog.open(this.dialog); + this.draftDeskService.newDraft( + DraftFormModel.sendAgain(this.mailViewerComponent.mailObj, this.draftDeskService.fromsSubject.value) + ).then(() => { + this.mailViewerComponent.close('goToDraftDesk'); + ProgressDialog.close(); + }); + } + public markSeen(seen_flag_value = 1) { this.updateMessages({ messageIds: [this.mailViewerComponent.messageId], diff --git a/src/app/mailviewer/singlemailviewer.component.html b/src/app/mailviewer/singlemailviewer.component.html index 5eaf9debb..3ecde2ef9 100644 --- a/src/app/mailviewer/singlemailviewer.component.html +++ b/src/app/mailviewer/singlemailviewer.component.html @@ -36,7 +36,10 @@ - + + @@ -44,7 +47,7 @@ - + @@ -55,7 +58,7 @@ - + @@ -63,21 +66,21 @@ - - - - - @@ -148,11 +151,15 @@ Forward - + - + - + - - View source - - Original HTML diff --git a/src/app/mailviewer/singlemailviewer.component.spec.ts b/src/app/mailviewer/singlemailviewer.component.spec.ts index d4bc44f93..cfb173a4a 100644 --- a/src/app/mailviewer/singlemailviewer.component.spec.ts +++ b/src/app/mailviewer/singlemailviewer.component.spec.ts @@ -30,6 +30,7 @@ import { MatExpansionModule } from '@angular/material/expansion'; import { MatGridListModule } from '@angular/material/grid-list'; import { MatIcon, MatIconModule } from '@angular/material/icon'; import { MatIconTestingModule } from '@angular/material/icon/testing'; +import { MatLegacyListModule as MatListModule } from '@angular/material/legacy-list'; import { MatLegacyMenuModule as MatMenuModule } from '@angular/material/legacy-menu'; import { MatLegacyRadioModule as MatRadioModule } from '@angular/material/legacy-radio'; import { MatToolbarModule } from '@angular/material/toolbar'; @@ -106,6 +107,7 @@ describe('SingleMailViewerComponent', () => { MatIconModule, MatIconTestingModule, MatGridListModule, + MatListModule, MatToolbarModule, MatTooltipModule, MatDividerModule, @@ -117,7 +119,7 @@ describe('SingleMailViewerComponent', () => { providers: [ MobileQueryService, StorageService, - { provide: MessageListService, useValue: { spamFolderName: 'Spam' }}, + { provide: MessageListService, useValue: { spamFolderName: 'Spam', trashFolderName: 'Trash' }}, { provide: HttpClient, useValue: {} }, { provide: PreferencesService, useValue: { preferences: new ReplaySubject>(), @@ -143,8 +145,17 @@ describe('SingleMailViewerComponent', () => { 'name': 'Testy' }] }, date: new Date(2016, 0, 1).toJSON(), - subject: 'Test subject' + subject: 'Test subject', + to: { + value: [{ 'address': 'to@runbox.com', + 'name': 'Recipient' }] + }, + bcc: { + value: [{ 'address': 'bcc@runbox.com', + 'name': 'Hidden Recipient' }] + } }, + folder: 'Sent.Subfolder', text: { text: 'blablabla', html: null, @@ -198,6 +209,9 @@ describe('SingleMailViewerComponent', () => { forward(useHTML?: boolean) { throw new Error('Method not implemented.'); } + sendAgain() { + throw new Error('Method not implemented.'); + } markSeen(seen_flag_value?: number) { throw new Error('Method not implemented.'); } @@ -227,6 +241,8 @@ describe('SingleMailViewerComponent', () => { expect(component.mailObj.attachments[0].downloadURL.indexOf('attachment/0')).toBeGreaterThan(-1); expect(component.mailObj.attachments[0].thumbnailURL.indexOf('attachmentimagethumbnail/0')).toBeGreaterThan(-1); + expect(component.mailObj.bcc[0].address).toBe('bcc@runbox.com'); + expect(component.isSentFolder).toBe(true); expect(component.mailObj.attachments[1].downloadURL.indexOf('blob:')).toBe(0); })); diff --git a/src/app/mailviewer/singlemailviewer.component.ts b/src/app/mailviewer/singlemailviewer.component.ts index 1bb81b032..6146e0134 100644 --- a/src/app/mailviewer/singlemailviewer.component.ts +++ b/src/app/mailviewer/singlemailviewer.component.ts @@ -129,6 +129,14 @@ export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit folder: string; + public get isSentFolder(): boolean { + return !!this.folder && this.folder.indexOf('Sent') === 0; + } + + public toolbarIndex(index: number): number { + return this.isSentFolder ? index + 1 : index; + } + constructor( private domSanitizer: DomSanitizer, @@ -537,6 +545,7 @@ export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit res.from = res.headers.from.value.map(f => new MailAddressInfo(f.name,f.address)); res.to = res.headers.to ? res.headers.to.value : ''; res.cc = res.headers.cc ? res.headers.cc.value : ''; + res.bcc = res.headers.bcc ? res.headers.bcc.value : ''; // RFC 5322 says "Date" and "From" are the only 2 required fields // and yet we get emails without em.