From ea22cbc91054dfcfb2933223ff2e222bcfe5f901 Mon Sep 17 00:00:00 2001 From: Kaan Date: Tue, 12 May 2026 01:58:42 +0300 Subject: [PATCH 1/3] test(mail): cover mismatched link warnings --- .../singlemailviewer.component.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/src/app/mailviewer/singlemailviewer.component.spec.ts b/src/app/mailviewer/singlemailviewer.component.spec.ts index d4bc44f93..099797707 100644 --- a/src/app/mailviewer/singlemailviewer.component.spec.ts +++ b/src/app/mailviewer/singlemailviewer.component.spec.ts @@ -426,6 +426,90 @@ describe('SingleMailViewerComponent', () => { ); })); + it('should warn before opening a link whose visible URL differs from its href', fakeAsync(() => { + const dialogRef = { + componentInstance: {}, + afterClosed: () => of(false) + } as any; + const dialogOpenSpy = spyOn(component.dialog, 'open').and.returnValue(dialogRef); + const link = document.createElement('a'); + link.setAttribute('href', 'https://phishing.example/login'); + link.textContent = 'https://runbox.com/login'; + messageContentsElement.appendChild(link); + + component['initMailtoInterceptor'](); + tick(); + + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }); + Object.defineProperty(clickEvent, 'target', { value: link, writable: false }); + const preventDefaultSpy = spyOn(clickEvent, 'preventDefault'); + + messageContentsElement.dispatchEvent(clickEvent); + tick(); + + expect(preventDefaultSpy).toHaveBeenCalled(); + expect(dialogOpenSpy).toHaveBeenCalled(); + expect(dialogRef.componentInstance.question).toContain('https://runbox.com/login'); + expect(dialogRef.componentInstance.question).toContain('https://phishing.example/login'); + })); + + it('should open a mismatched link only after the warning is accepted', fakeAsync(() => { + const dialogRef = { + componentInstance: {}, + afterClosed: () => of(true) + } as any; + spyOn(component.dialog, 'open').and.returnValue(dialogRef); + const windowOpenSpy = spyOn(window, 'open').and.returnValue(null); + const link = document.createElement('a'); + link.setAttribute('href', 'https://phishing.example/login'); + link.textContent = 'https://runbox.com/login'; + messageContentsElement.appendChild(link); + + component['initMailtoInterceptor'](); + tick(); + + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }); + Object.defineProperty(clickEvent, 'target', { value: link, writable: false }); + + messageContentsElement.dispatchEvent(clickEvent); + tick(); + + expect(windowOpenSpy).toHaveBeenCalledWith('https://phishing.example/login', '_blank', 'noopener'); + })); + + it('should not warn when the visible URL matches the href', fakeAsync(() => { + const dialogOpenSpy = spyOn(component.dialog, 'open'); + const link = document.createElement('a'); + link.setAttribute('href', 'https://runbox.com/login'); + link.textContent = 'https://runbox.com/login'; + messageContentsElement.appendChild(link); + + component['initMailtoInterceptor'](); + tick(); + + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + view: window + }); + Object.defineProperty(clickEvent, 'target', { value: link, writable: false }); + const preventDefaultSpy = spyOn(clickEvent, 'preventDefault'); + + messageContentsElement.dispatchEvent(clickEvent); + tick(); + + expect(preventDefaultSpy).not.toHaveBeenCalled(); + expect(dialogOpenSpy).not.toHaveBeenCalled(); + })); + it('should remove old event listener when re-initializing', fakeAsync(() => { mailtoLink = document.createElement('a'); mailtoLink.href = 'mailto:first@example.com'; From 76fdf03efdf46370dd3f78c4def1559c24e735c8 Mon Sep 17 00:00:00 2001 From: Kaan Date: Tue, 12 May 2026 02:01:19 +0300 Subject: [PATCH 2/3] fix(mail): warn on mismatched displayed links --- .../mailviewer/singlemailviewer.component.ts | 149 +++++++++++++++--- 1 file changed, 129 insertions(+), 20 deletions(-) diff --git a/src/app/mailviewer/singlemailviewer.component.ts b/src/app/mailviewer/singlemailviewer.component.ts index 1bb81b032..09204805c 100644 --- a/src/app/mailviewer/singlemailviewer.component.ts +++ b/src/app/mailviewer/singlemailviewer.component.ts @@ -47,6 +47,7 @@ import { loadLocalMailParser } from './mailparser'; import { RunboxContactSupportSnackBar } from '../common/contact-support-snackbar.service'; import { ContactsService } from '../contacts-app/contacts.service'; import { Contact, ContactKind } from '../contacts-app/contact'; +import { ConfirmDialog } from '../dialog/dialog.module'; import { ShowHTMLDialogComponent } from '../dialog/htmlconfirm.dialog'; import { MessageTableRowTool} from '../messagetable/messagetablerow'; import { PreferencesService } from '../common/preferences.service'; @@ -72,6 +73,7 @@ type Mail = any; }) export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit { private lastMailtoInterceptorNode: HTMLElement | null = null; + private lastIframeInterceptorDocument: Document | null = null; _messageId = null; // Message id or filename @@ -183,8 +185,8 @@ export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit } /** - * Intercepts clicks on mailto: links in the message content area, - * opens compose view instead of default browser email client. + * Intercepts clicks on message links to route mailto: links internally and + * warn before opening links where the visible URL differs from the href. */ private initMailtoInterceptor() { // Remove old listener if any @@ -196,28 +198,134 @@ export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit // Use a bound handler to guarantee removal works this._mailtoInterceptorListener = (event: MouseEvent) => { - let target = event.target as HTMLElement; - while (target && target.tagName !== 'A' && target !== messageContents) { - target = target.parentElement; - } - if ( - target && - target.tagName === 'A' && - target.getAttribute('href')?.toLowerCase().startsWith('mailto:') - ) { - event.preventDefault(); - const href = target.getAttribute('href'); - if (href) { - const matches = /^mailto:([^?]+)/i.exec(href); - const emailTo = matches ? matches[1] : ''; - this.goToDraftDeskWithTo(emailTo); - } - } + this.handleMessageLinkClick(event, messageContents); }; messageContents.addEventListener('click', this._mailtoInterceptorListener, true); this.lastMailtoInterceptorNode = messageContents; } - private _mailtoInterceptorListener: any; + private _mailtoInterceptorListener: ((event: MouseEvent) => void) | null = null; + private _iframeLinkInterceptorListener: ((event: MouseEvent) => void) | null = null; + + private initIframeLinkInterceptor() { + if (this.lastIframeInterceptorDocument && this._iframeLinkInterceptorListener) { + this.lastIframeInterceptorDocument.removeEventListener('click', this._iframeLinkInterceptorListener, true); + } + + const iframeDocument = this.htmliframe?.nativeElement?.contentWindow?.document; + if (!iframeDocument) return; + + this._iframeLinkInterceptorListener = (event: MouseEvent) => { + this.handleMessageLinkClick(event, iframeDocument); + }; + iframeDocument.addEventListener('click', this._iframeLinkInterceptorListener, true); + this.lastIframeInterceptorDocument = iframeDocument; + } + + private handleMessageLinkClick(event: MouseEvent, boundary: HTMLElement | Document) { + const target = this.findClickedAnchor(event.target, boundary); + const href = target?.getAttribute('href'); + if (!target || !href) return; + + if (href.toLowerCase().startsWith('mailto:')) { + event.preventDefault(); + const matches = /^mailto:([^?]+)/i.exec(href); + const emailTo = matches ? matches[1] : ''; + this.goToDraftDeskWithTo(emailTo); + return; + } + + const linkMismatch = this.getDisplayedUrlMismatch(target); + if (linkMismatch) { + event.preventDefault(); + this.warnBeforeOpeningMismatchedLink(linkMismatch); + } + } + + private findClickedAnchor(eventTarget: EventTarget | null, boundary: HTMLElement | Document): HTMLAnchorElement | null { + let target = eventTarget as Node; + if (target && target.nodeType === 3) { + target = target.parentElement; + } + + while (target && target !== boundary) { + const element = target as HTMLElement; + if (element.tagName === 'A') { + return element as HTMLAnchorElement; + } + target = element.parentElement; + } + + return null; + } + + private getDisplayedUrlMismatch(anchor: HTMLAnchorElement): { visibleUrl: string; href: string } | null { + const href = anchor.getAttribute('href'); + if (!href) return null; + + const actualUrl = this.parseAbsoluteNavigableUrl(href); + const visibleUrlText = this.extractDisplayedUrl(anchor.textContent || ''); + if (!actualUrl || !visibleUrlText) return null; + + const visibleUrl = this.parseDisplayedUrl(visibleUrlText, actualUrl.protocol); + if (!visibleUrl || visibleUrl.href === actualUrl.href) return null; + + return { + visibleUrl: visibleUrl.href, + href: actualUrl.href + }; + } + + private extractDisplayedUrl(text: string): string | null { + const normalizedText = text.replace(/\s+/g, ' ').trim(); + const match = /(?:https?:\/\/|ftp:\/\/|www\.)[^\s<>"']+/i.exec(normalizedText); + return match ? this.trimUrlPunctuation(match[0]) : null; + } + + private trimUrlPunctuation(urlText: string): string { + return urlText + .replace(/^[([{'"<]+/, '') + .replace(/[)\].,;:!?'"<>]+$/, ''); + } + + private parseDisplayedUrl(urlText: string, defaultProtocol: string): URL | null { + const normalizedUrlText = /^www\./i.test(urlText) + ? `${defaultProtocol}//${urlText}` + : urlText; + + return this.parseAbsoluteNavigableUrl(normalizedUrlText); + } + + private parseAbsoluteNavigableUrl(urlText: string): URL | null { + const normalizedUrlText = urlText.startsWith('//') + ? `${window.location.protocol}${urlText}` + : urlText; + + if (!/^(https?:\/\/|ftp:\/\/)/i.test(normalizedUrlText)) return null; + + try { + const url = new URL(normalizedUrlText); + return ['http:', 'https:', 'ftp:'].includes(url.protocol) ? url : null; + } catch (_err) { + return null; + } + } + + private warnBeforeOpeningMismatchedLink(linkMismatch: { visibleUrl: string; href: string }) { + const confirmDialog = this.dialog.open(ConfirmDialog); + confirmDialog.componentInstance.title = 'Check link before opening'; + confirmDialog.componentInstance.question = + `The link text shows ${linkMismatch.visibleUrl}, but it opens ${linkMismatch.href}. Open it anyway?`; + confirmDialog.componentInstance.noOptionTitle = 'cancel'; + confirmDialog.componentInstance.yesOptionTitle = 'open link'; + confirmDialog.afterClosed().subscribe(result => { + if (result) { + const openedWindow = window.open(linkMismatch.href, '_blank', 'noopener'); + if (openedWindow) { + openedWindow.opener = null; + } + } + }); + } /** @@ -685,6 +793,7 @@ export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit const iframe = document.getElementById('iframe'); const newHeight = this.htmliframe.nativeElement.contentWindow.document.body.scrollHeight; iframe.style.cssText = `height: ${newHeight + 70}px !important`; + this.initIframeLinkInterceptor(); } } From cd178936f0878070a898b8402032417935464c90 Mon Sep 17 00:00:00 2001 From: Kaan Date: Tue, 12 May 2026 02:55:07 +0300 Subject: [PATCH 3/3] fix(mail): preserve user activation for link warnings --- src/app/dialog/confirmdialog.component.html | 7 ++++++- src/app/dialog/confirmdialog.component.ts | 1 + src/app/mailviewer/singlemailviewer.component.spec.ts | 6 +++--- src/app/mailviewer/singlemailviewer.component.ts | 9 +-------- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/src/app/dialog/confirmdialog.component.html b/src/app/dialog/confirmdialog.component.html index 0935e3646..f9eb06870 100644 --- a/src/app/dialog/confirmdialog.component.html +++ b/src/app/dialog/confirmdialog.component.html @@ -4,7 +4,12 @@

{{title}}

- diff --git a/src/app/dialog/confirmdialog.component.ts b/src/app/dialog/confirmdialog.component.ts index ef0a852d3..471b11fed 100644 --- a/src/app/dialog/confirmdialog.component.ts +++ b/src/app/dialog/confirmdialog.component.ts @@ -32,6 +32,7 @@ import { MatLegacyDialogRef as MatDialogRef } from '@angular/material/legacy-dia // eslint-disable-next-line @angular-eslint/component-class-suffix export class ConfirmDialog { yesOptionTitle = 'ok'; + yesOptionHref: string = null; noOptionTitle = 'cancel'; title = 'Please confirm action'; question = 'Are you sure?'; diff --git a/src/app/mailviewer/singlemailviewer.component.spec.ts b/src/app/mailviewer/singlemailviewer.component.spec.ts index 099797707..7d2a4b546 100644 --- a/src/app/mailviewer/singlemailviewer.component.spec.ts +++ b/src/app/mailviewer/singlemailviewer.component.spec.ts @@ -455,15 +455,15 @@ describe('SingleMailViewerComponent', () => { expect(dialogOpenSpy).toHaveBeenCalled(); expect(dialogRef.componentInstance.question).toContain('https://runbox.com/login'); expect(dialogRef.componentInstance.question).toContain('https://phishing.example/login'); + expect(dialogRef.componentInstance.yesOptionHref).toBe('https://phishing.example/login'); })); - it('should open a mismatched link only after the warning is accepted', fakeAsync(() => { + it('should render the accepted mismatched link as a dialog action href', fakeAsync(() => { const dialogRef = { componentInstance: {}, afterClosed: () => of(true) } as any; spyOn(component.dialog, 'open').and.returnValue(dialogRef); - const windowOpenSpy = spyOn(window, 'open').and.returnValue(null); const link = document.createElement('a'); link.setAttribute('href', 'https://phishing.example/login'); link.textContent = 'https://runbox.com/login'; @@ -482,7 +482,7 @@ describe('SingleMailViewerComponent', () => { messageContentsElement.dispatchEvent(clickEvent); tick(); - expect(windowOpenSpy).toHaveBeenCalledWith('https://phishing.example/login', '_blank', 'noopener'); + expect(dialogRef.componentInstance.yesOptionHref).toBe('https://phishing.example/login'); })); it('should not warn when the visible URL matches the href', fakeAsync(() => { diff --git a/src/app/mailviewer/singlemailviewer.component.ts b/src/app/mailviewer/singlemailviewer.component.ts index 09204805c..8e0b55308 100644 --- a/src/app/mailviewer/singlemailviewer.component.ts +++ b/src/app/mailviewer/singlemailviewer.component.ts @@ -317,14 +317,7 @@ export class SingleMailViewerComponent implements OnInit, DoCheck, AfterViewInit `The link text shows ${linkMismatch.visibleUrl}, but it opens ${linkMismatch.href}. Open it anyway?`; confirmDialog.componentInstance.noOptionTitle = 'cancel'; confirmDialog.componentInstance.yesOptionTitle = 'open link'; - confirmDialog.afterClosed().subscribe(result => { - if (result) { - const openedWindow = window.open(linkMismatch.href, '_blank', 'noopener'); - if (openedWindow) { - openedWindow.opener = null; - } - } - }); + confirmDialog.componentInstance.yesOptionHref = linkMismatch.href; }