From 8ffe449f15b769c4fe4f3045b471a6b155f62954 Mon Sep 17 00:00:00 2001 From: kei Date: Thu, 11 Jun 2026 07:40:16 +0200 Subject: [PATCH 1/2] test(compose): cover identity default bcc --- src/app/compose/draftdesk.service.spec.ts | 72 +++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/app/compose/draftdesk.service.spec.ts b/src/app/compose/draftdesk.service.spec.ts index 0b4f6bbff..a81c2f399 100644 --- a/src/app/compose/draftdesk.service.spec.ts +++ b/src/app/compose/draftdesk.service.spec.ts @@ -410,6 +410,78 @@ Subject: Test subject
expect(draft.isUnsaved()).toBe(false); done(); }); + + it('Create: applies identity default bcc only to new drafts', (done) => { + let draft = DraftFormModel.create( + -1, + Identity.fromObject({'email': 'sender@runbox.com', 'default_bcc': 'archive@runbox.com'}), + 'recipient@runbox.com', + 'Test subject'); + + expect(draft.bcc.length).toBe(1); + expect(draft.bcc[0].address).toBe('archive@runbox.com'); + + draft = DraftFormModel.create( + 12345, + Identity.fromObject({'email': 'sender@runbox.com', 'default_bcc': 'archive@runbox.com'}), + 'recipient@runbox.com', + 'Saved subject'); + + expect(draft.bcc).toEqual([]); + done(); + }); + + it('Reply: applies identity default bcc from the selected From identity', (done) => { + const replydraft = DraftFormModel.reply({ + headers: { + 'message-id': 'reply-default-bcc', + }, + from: [ + {address: 'sender@runbox.com', name: 'Sender'} + ], + to: [ + {address: 'identity@runbox.com', name: 'Identity'} + ], + date: mailDate, + subject: 'Test subject', + rawtext: 'blabla', + sanitized_html: '

blabla

', + }, + [ Identity.fromObject({'email': 'identity@runbox.com', 'default_bcc': 'archive@runbox.com'})], + false, + false); + + expect(replydraft.from).toBe('identity@runbox.com'); + expect(replydraft.bcc.length).toBe(1); + expect(replydraft.bcc[0].address).toBe('archive@runbox.com'); + done(); + }); + + it('Forward: applies identity default bcc from the selected From identity', (done) => { + const draft = DraftFormModel.forward({ + headers: { + 'message-id': 'forward-default-bcc', + }, + from: [ + {address: 'sender@runbox.com', name: 'Sender'} + ], + to: [ + {address: 'identity@runbox.com', name: 'Identity'} + ], + attachments: [], + date: mailDate, + subject: 'Test subject', + rawtext: 'blabla', + sanitized_html: '

blabla

', + }, + [ Identity.fromObject({'email': 'identity@runbox.com', 'default_bcc': 'archive@runbox.com'})], + false); + + expect(draft.from).toBe('identity@runbox.com'); + expect(draft.bcc.length).toBe(1); + expect(draft.bcc[0].address).toBe('archive@runbox.com'); + done(); + }); }); describe('DraftDeskService', () => { From 9cb31ffa3cd307b312c04652c284cf021bbbd3f1 Mon Sep 17 00:00:00 2001 From: kei Date: Thu, 11 Jun 2026 07:47:41 +0200 Subject: [PATCH 2/2] fix(compose): apply identity default bcc --- src/app/compose/compose.component.ts | 68 +++++++++++++++++++-- src/app/compose/draftdesk.service.ts | 35 ++++++++--- src/app/profiles/profile.service.ts | 1 + src/app/profiles/profiles.editor.modal.html | 11 ++++ src/app/profiles/profiles.editor.modal.ts | 2 + 5 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/app/compose/compose.component.ts b/src/app/compose/compose.component.ts index e1aa9ecf2..5d24c503c 100644 --- a/src/app/compose/compose.component.ts +++ b/src/app/compose/compose.component.ts @@ -83,6 +83,7 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { has_pasted_signature: boolean; signature: string; selector: string; + private defaultBccRecipients: MailAddressInfo[] = []; saveErrorMessage: string; @@ -196,6 +197,15 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { this.formGroup = this.formBuilder.group(this.model); + if (this.model.isUnsaved()) { + this.updateDefaultBcc(this.formGroup.controls.from.value, false); + } else { + this.trackDefaultBcc(this.formGroup.controls.from.value); + } + + this.formGroup.controls.from.valueChanges + .subscribe((selected_from_address) => this.updateDefaultBcc(selected_from_address)); + // Mark not saved if changes this.formGroup.valueChanges.subscribe(() => this.saved = null); @@ -222,8 +232,10 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { this.formGroup.controls.from.valueChanges .pipe(debounceTime(1000)) .subscribe((selected_from_address) => { - const from: Identity = this.draftDeskservice.fromsSubject.value.find((f) => - f.nameAndAddress === selected_from_address); + const from: Identity = this.identityForAddress(selected_from_address); + if (!from) { + return; + } if ( this.formGroup.controls.msg_body.pristine ) { if ( this.signature && from.signature ) { // replaces current signature with new one @@ -525,6 +537,7 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { this.draftDeskservice.shouldReturnToPreviousPage = false; this.formGroup.patchValue(this.model, { emitEvent: false }); + this.trackDefaultBcc(this.formGroup.controls.from.value); this.htmlToggled(); } @@ -696,6 +709,8 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { this.savingInProgress = true; if (send) { + this.updateDefaultBcc(this.formGroup.controls.from.value, false); + let validemails = false; validemails = isValidEmailArray(this.model.to); if (!validemails) { @@ -736,8 +751,7 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { } // this.model.from should have a value (cos it defaults) - const from = this.draftDeskservice.fromsSubject.value.find( - (f) => this.model.from === f.nameAndAddress || this.model.from === f.email); + const from = this.identityForAddress(this.model.from); let draft_from = this.model.from; if (!from) { console.log(`Compose: Could not find ${this.model.from} in ${this.draftDeskservice.fromsSubject.value}`); @@ -915,4 +929,50 @@ export class ComposeComponent implements AfterViewInit, OnDestroy, OnInit { s => !currentrecipients.find(r => r.address === s.address) ).slice(0, 10); } + + private updateDefaultBcc(selectedFromAddress: string, emitEvent = true): void { + if (!this.formGroup || !this.formGroup.controls.bcc) { + return; + } + + const nextDefaultBcc = DraftFormModel.defaultBccRecipients(this.identityForAddress(selectedFromAddress)); + const currentBcc: MailAddressInfo[] = this.model.bcc || []; + const bccWithoutPreviousDefault = currentBcc.filter((recipient) => + !this.defaultBccRecipients.some((defaultRecipient) => this.sameEmailAddress(recipient, defaultRecipient))); + const mergedBcc = bccWithoutPreviousDefault.slice(); + + for (const defaultRecipient of nextDefaultBcc) { + if (!mergedBcc.some((recipient) => this.sameEmailAddress(recipient, defaultRecipient))) { + mergedBcc.push(defaultRecipient); + } + } + + this.defaultBccRecipients = nextDefaultBcc; + this.model.bcc = mergedBcc; + this.hasBCC = mergedBcc.length > 0; + this.formGroup.controls.bcc.setValue(mergedBcc, { emitEvent }); + } + + private trackDefaultBcc(selectedFromAddress: string): void { + this.defaultBccRecipients = DraftFormModel.defaultBccRecipients(this.identityForAddress(selectedFromAddress)); + } + + private identityForAddress(selectedFromAddress: string): Identity { + if (!selectedFromAddress) { + return null; + } + + const selectedAddresses = MailAddressInfo.parse(selectedFromAddress) + .map((address) => address.address.toLowerCase()) + .filter((address) => address); + return this.draftDeskservice.fromsSubject.value.find((identity) => + identity.nameAndAddress === selectedFromAddress || + identity.email === selectedFromAddress || + selectedAddresses.includes(identity.email.toLowerCase()) + ); + } + + private sameEmailAddress(left: MailAddressInfo, right: MailAddressInfo): boolean { + return (left?.address || '').toLowerCase() === (right?.address || '').toLowerCase(); + } } diff --git a/src/app/compose/draftdesk.service.ts b/src/app/compose/draftdesk.service.ts index 6f1efa0be..6e131453e 100644 --- a/src/app/compose/draftdesk.service.ts +++ b/src/app/compose/draftdesk.service.ts @@ -76,6 +76,9 @@ export class DraftFormModel { ret.from = fromAddress.email; ret.mid = draftId; ret.to = to ? MailAddressInfo.parse(to) : []; + if (ret.isUnsaved()) { + ret.bcc = DraftFormModel.defaultBccRecipients(fromAddress); + } ret.subject = subject; if (preview) { // We create an element here because we want the plain text @@ -126,7 +129,8 @@ export class DraftFormModel { }); } } - ret.setFromForResponse(mailObj, froms); + const selectedFrom = ret.setFromForResponse(mailObj, froms); + ret.bcc = DraftFormModel.defaultBccRecipients(selectedFrom); ret.setSubjectForResponse(mailObj, 'Re: '); const localTZ = moment.tz.guess(); @@ -166,9 +170,18 @@ export class DraftFormModel { return ret; } + public static defaultBccRecipients(identity: Identity): MailAddressInfo[] { + if (!identity || !identity.default_bcc || !identity.default_bcc.trim()) { + return []; + } + return MailAddressInfo.parse(identity.default_bcc) + .filter(recipient => recipient.address); + } + public static forward(mailObj, froms: Identity[], useHTML: boolean): DraftFormModel { const ret = new DraftFormModel(); - ret.setFromForResponse(mailObj, froms); + const selectedFrom = ret.setFromForResponse(mailObj, froms); + ret.bcc = DraftFormModel.defaultBccRecipients(selectedFrom); const fwdFromNameStr = mailObj.from[0].name ? `"${mailObj.from[0].name}" <${mailObj.from[0].address}>` @@ -216,15 +229,19 @@ ${mailObj.sanitized_html}`; return false; } - private setFromForResponse(mailObj, froms: Identity[]): void { - if (froms.length > 0) { - this.from = ( - [].concat(mailObj.to || []).concat(mailObj.cc || []).find( - addr => froms.find(fromObj => fromObj.email === addr.address.toLowerCase()) - ) || { address: froms[0].email } - ).address.toLowerCase(); + private setFromForResponse(mailObj, froms: Identity[]): Identity { + if (froms && froms.length > 0) { + const recipientAddress = [].concat(mailObj.to || []).concat(mailObj.cc || []).find( + addr => froms.find(fromObj => fromObj.email.toLowerCase() === addr.address.toLowerCase()) + ); + const selectedFrom = recipientAddress + ? froms.find(fromObj => fromObj.email.toLowerCase() === recipientAddress.address.toLowerCase()) + : froms[0]; + this.from = selectedFrom.email.toLowerCase(); + return selectedFrom; } else { console.error('DraftDesk: No froms passed to setFromForResponse'); + return null; } } diff --git a/src/app/profiles/profile.service.ts b/src/app/profiles/profile.service.ts index 56ad178a8..0bf856c25 100644 --- a/src/app/profiles/profile.service.ts +++ b/src/app/profiles/profile.service.ts @@ -36,6 +36,7 @@ export class Identity { name: string; reference_type: string; reply_to: string; + default_bcc: string; signature: string; type: string; smtp_address: string; diff --git a/src/app/profiles/profiles.editor.modal.html b/src/app/profiles/profiles.editor.modal.html index 02007f5c2..bcfeec338 100644 --- a/src/app/profiles/profiles.editor.modal.html +++ b/src/app/profiles/profiles.editor.modal.html @@ -88,6 +88,17 @@ + + +
+ ie. archive@runbox.com + + {{error}} + +
+
+ diff --git a/src/app/profiles/profiles.editor.modal.ts b/src/app/profiles/profiles.editor.modal.ts index d2451e4fe..21425c1b1 100644 --- a/src/app/profiles/profiles.editor.modal.ts +++ b/src/app/profiles/profiles.editor.modal.ts @@ -104,6 +104,7 @@ export class ProfilesEditorModalComponent implements OnDestroy { email: identity.email, from_name: identity.from_name, reply_to: identity.reply_to, + default_bcc: identity.default_bcc, signature: identity.signature, smtp_address: identity.smtp_address, smtp_port: identity.smtp_port, @@ -130,6 +131,7 @@ export class ProfilesEditorModalComponent implements OnDestroy { email: identity.email, from_name: identity.from_name, reply_to: identity.reply_to, + default_bcc: identity.default_bcc, signature: identity.signature, smtp_address: identity.smtp_address, smtp_port: identity.smtp_port,