Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/app/dialog/confirmdialog.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ <h1 mat-dialog-title>{{title}}</h1>
<p style="flex-grow: 1">

</p>
<button mat-icon-button
<a *ngIf="yesOptionHref" mat-icon-button
[href]="yesOptionHref" target="_blank" rel="noopener"
[matTooltip]="yesOptionTitle" (click)="dialogRef.close(true)">
<mat-icon svgIcon="check"></mat-icon>
</a>
<button *ngIf="!yesOptionHref" mat-icon-button
[matTooltip]="yesOptionTitle" (click)="dialogRef.close(true)">
<mat-icon svgIcon="check"></mat-icon>
</button>
Expand Down
1 change: 1 addition & 0 deletions src/app/dialog/confirmdialog.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?';
Expand Down
84 changes: 84 additions & 0 deletions src/app/mailviewer/singlemailviewer.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
expect(dialogRef.componentInstance.yesOptionHref).toBe('https://phishing.example/login');
}));

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 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(dialogRef.componentInstance.yesOptionHref).toBe('https://phishing.example/login');
}));

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';
Expand Down
142 changes: 122 additions & 20 deletions src/app/mailviewer/singlemailviewer.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -196,28 +198,127 @@ 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.componentInstance.yesOptionHref = linkMismatch.href;
}


/**
Expand Down Expand Up @@ -685,6 +786,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();
}
}

Expand Down