mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: open address links in the built-in composer
Clicking an address in the app's own chrome - the recipient card, the contact sidebar, the contact details - handed a mailto: URL to the OS mail handler. In a webmail client that goes nowhere: the browser asks which application should open the link, and the message is composed somewhere else, if at all. Only users who registered Bulwark itself as their system mailto handler got a usable result, and even then via a detour through the OS into a new tab. A new MailtoLink component sends the click to the built-in composer instead. It stays a real anchor with an href, so "Copy email address", middle click and assistive technology keep working; only the plain left click is redirected, and only when a listener takes it - otherwise the browser's own behaviour still applies. Modified clicks are left alone. The request reaches the mail page through a window event, the way `bulwark:rate-limit-blocked` already does, rather than threading a callback through three component levels and five call sites. It goes to openMailtoDraft rather than the protocol path: an in-app click carries its own context - the account being read - so the account picker that an external mailto: needs would only get in the way here. Message content is deliberately untouched. Plain text bodies render into the main document rather than the sandboxed frame and their sanitizer permits mailto:, so a document-wide interceptor would have swept up untrusted links too; a component cannot be reached from message HTML at all.
This commit is contained in:
@@ -73,7 +73,7 @@ import { usePluginStore } from "@/stores/plugin-store";
|
||||
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session";
|
||||
import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { INTERNAL_MAILTO_EVENT, type ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
||||
import { plainTextToComposerBody, getQuoteBodies } from "@/lib/email-composer-utils";
|
||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||
import { emailToReadView } from "@/lib/plugin-projection";
|
||||
@@ -958,6 +958,25 @@ export default function Home() {
|
||||
return subscribeToPendingMailto(openPendingMailto);
|
||||
}, [isAuthenticated, client, handleMailtoProtocolRequest]);
|
||||
|
||||
// Address links in the app's own chrome (contact cards, sidebars, contact
|
||||
// details) compose in place. Unlike a request arriving from the OS handler,
|
||||
// an in-app click carries its own context - the account being read - so this
|
||||
// goes straight to the composer instead of through the account picker.
|
||||
// Cancelling the event tells the link its click was taken.
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
const onInternalMailto = (event: Event) => {
|
||||
const pending = (event as CustomEvent<ParsedMailto>).detail;
|
||||
if (!pending) return;
|
||||
event.preventDefault();
|
||||
openMailtoDraft(pending);
|
||||
};
|
||||
|
||||
window.addEventListener(INTERNAL_MAILTO_EVENT, onInternalMailto);
|
||||
return () => window.removeEventListener(INTERNAL_MAILTO_EVENT, onInternalMailto);
|
||||
}, [isAuthenticated, client, openMailtoDraft]);
|
||||
|
||||
// Fallback fetch for paths that didn't go through login()'s prefetch
|
||||
// (notably checkAuth on page refresh). Settings pages can also prefill
|
||||
// mailboxes without emails, so bootstrap emails once per client when the
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreHorizontal, Printer } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate, PartialDate } from "@/lib/jmap/types";
|
||||
@@ -232,18 +233,18 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
{emails.map((e, i) => (
|
||||
<FieldRow key={`em${i}`} icon={Mail} label={e.label || formatContexts(e.contexts) || t("detail.email_default_label")}>
|
||||
<div className="flex items-center gap-2 group">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline break-all">
|
||||
<MailtoLink to={e.address} className="text-sm text-primary hover:underline break-all">
|
||||
{e.address}
|
||||
</a>
|
||||
</MailtoLink>
|
||||
<RowActions>
|
||||
<a
|
||||
href={`mailto:${e.address}`}
|
||||
<MailtoLink
|
||||
to={e.address}
|
||||
className="p-1.5 rounded hover:bg-muted transition-colors touch-manipulation"
|
||||
title={t("detail.compose_email")}
|
||||
aria-label={t("detail.compose_email")}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</a>
|
||||
</MailtoLink>
|
||||
<CopyButton value={e.address} label={t("detail.copy_email")} successMsg={t("detail.copied")} failMsg={t("detail.copy_failed")} />
|
||||
</RowActions>
|
||||
</div>
|
||||
|
||||
@@ -97,6 +97,7 @@ import { useTour } from "@/components/tour/tour-provider";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
|
||||
import { RecipientPopover } from "./recipient-popover";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { isFilePreviewable, isMimeTypeSafeForInlinePreview } from "@/lib/file-preview";
|
||||
import { parseTnef, isTnefAttachment } from "@/lib/tnef";
|
||||
import { debug } from "@/lib/debug";
|
||||
@@ -419,14 +420,14 @@ export function ContactSidebarPanel({
|
||||
|
||||
{/* Quick actions */}
|
||||
<div className="px-4 pb-4 flex items-center justify-center gap-2">
|
||||
<a
|
||||
href={`mailto:${primaryEmail}`}
|
||||
<MailtoLink
|
||||
to={primaryEmail}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||
title={t('contact_sidebar.action_email_title')}
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
{t('contact_sidebar.action_email')}
|
||||
</a>
|
||||
</MailtoLink>
|
||||
<button
|
||||
onClick={() => handleCopy(primaryEmail)}
|
||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground px-3 py-2 rounded-md hover:bg-muted transition-colors border border-border"
|
||||
@@ -455,9 +456,9 @@ export function ContactSidebarPanel({
|
||||
<SidebarSection icon={Mail} title={t('contact_sidebar.section_emails')}>
|
||||
{emails.map((e, i) => (
|
||||
<div key={i} className="flex items-center gap-2 group">
|
||||
<a href={`mailto:${e.address}`} className="text-sm text-primary hover:underline truncate">
|
||||
<MailtoLink to={e.address} className="text-sm text-primary hover:underline truncate">
|
||||
{e.address}
|
||||
</a>
|
||||
</MailtoLink>
|
||||
<button
|
||||
onClick={() => handleCopy(e.address)}
|
||||
className="p-1 rounded hover:bg-muted transition-colors opacity-0 group-hover:opacity-100 shrink-0"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createPortal } from "react-dom";
|
||||
import { Mail, Phone, Building, ExternalLink, Copy, Send, UserPlus } from "lucide-react";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { useContactStore, getContactDisplayName } from "@/stores/contact-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import type { ContactCard } from "@/lib/jmap/types";
|
||||
@@ -210,14 +211,14 @@ export function RecipientPopover({ name, email, displayLabel, onViewContact, cla
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
Copy
|
||||
</button>
|
||||
<a
|
||||
href={`mailto:${email}`}
|
||||
<MailtoLink
|
||||
to={email}
|
||||
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground px-2 py-1.5 rounded hover:bg-muted transition-colors"
|
||||
title="Send email"
|
||||
>
|
||||
<Send className="w-3.5 h-3.5" />
|
||||
Email
|
||||
</a>
|
||||
</MailtoLink>
|
||||
{onViewContact && (
|
||||
<button
|
||||
onClick={handleViewContact}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { render, screen, cleanup, fireEvent } from '@testing-library/react';
|
||||
import { MailtoLink } from '../mailto-link';
|
||||
import { INTERNAL_MAILTO_EVENT } from '@/lib/protocol-handlers/mailto';
|
||||
import type { ParsedMailto } from '@/lib/protocol-handlers/mailto';
|
||||
|
||||
/**
|
||||
* Address links in the app's own chrome must compose in place: handing a
|
||||
* mailto: URL to the OS mail handler goes nowhere in a webmail client. The
|
||||
* element stays an anchor with a real href so "Copy email address", middle
|
||||
* click and assistive technology keep working - only the plain click is
|
||||
* redirected, and only when a listener actually takes it.
|
||||
*/
|
||||
|
||||
function listenAndHandle(): { seen: ParsedMailto[] } {
|
||||
const seen: ParsedMailto[] = [];
|
||||
const handler = (event: Event) => {
|
||||
seen.push((event as CustomEvent<ParsedMailto>).detail);
|
||||
event.preventDefault();
|
||||
};
|
||||
window.addEventListener(INTERNAL_MAILTO_EVENT, handler);
|
||||
listeners.push(() => window.removeEventListener(INTERNAL_MAILTO_EVENT, handler));
|
||||
return { seen };
|
||||
}
|
||||
|
||||
let listeners: Array<() => void> = [];
|
||||
|
||||
describe('MailtoLink', () => {
|
||||
beforeEach(() => {
|
||||
listeners = [];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
listeners.forEach((off) => off());
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('keeps a real mailto href so copy/middle-click still work', () => {
|
||||
render(<MailtoLink to="ada@example.com">Email</MailtoLink>);
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'mailto:ada@example.com');
|
||||
});
|
||||
|
||||
it('requests the internal composer instead of the OS handler', async () => {
|
||||
const { seen } = listenAndHandle();
|
||||
render(<MailtoLink to="ada@example.com">Email</MailtoLink>);
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
const click = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
|
||||
link.dispatchEvent(click);
|
||||
|
||||
expect(seen).toHaveLength(1);
|
||||
expect(seen[0].to).toEqual(['ada@example.com']);
|
||||
// Default prevented => the browser does not follow the mailto: URL.
|
||||
expect(click.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('passes subject and cc through when given a full mailto URL', () => {
|
||||
const { seen } = listenAndHandle();
|
||||
render(<MailtoLink to="mailto:ada@example.com?subject=Hi&cc=bob@example.com">Email</MailtoLink>);
|
||||
|
||||
screen.getByRole('link').dispatchEvent(
|
||||
new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 }),
|
||||
);
|
||||
|
||||
expect(seen[0].subject).toBe('Hi');
|
||||
expect(seen[0].cc).toEqual(['bob@example.com']);
|
||||
});
|
||||
|
||||
it('leaves the click alone when no listener takes it', () => {
|
||||
render(<MailtoLink to="ada@example.com">Email</MailtoLink>);
|
||||
|
||||
const click = new MouseEvent('click', { bubbles: true, cancelable: true, button: 0 });
|
||||
screen.getByRole('link').dispatchEvent(click);
|
||||
|
||||
// Nothing handled it - fall back to the browser rather than swallowing.
|
||||
expect(click.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves modified clicks to the browser', () => {
|
||||
const { seen } = listenAndHandle();
|
||||
render(<MailtoLink to="ada@example.com">Email</MailtoLink>);
|
||||
|
||||
// Ctrl-click / middle click are the user's own "open elsewhere" gestures.
|
||||
fireEvent.click(screen.getByRole('link'), { ctrlKey: true });
|
||||
fireEvent.click(screen.getByRole('link'), { button: 1 });
|
||||
|
||||
expect(seen).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import type { AnchorHTMLAttributes, MouseEvent, ReactNode } from "react";
|
||||
import { requestInternalMailto } from "@/lib/protocol-handlers/mailto";
|
||||
|
||||
interface MailtoLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, "href" | "onClick"> {
|
||||
/** Bare address, or a full mailto: URL when subject/body are needed. */
|
||||
to: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* An address in the app's own chrome (contact cards, sidebars, contact
|
||||
* details). Handing a `mailto:` URL to the OS mail handler goes nowhere in a
|
||||
* webmail client - unless the user happened to register Bulwark as their
|
||||
* system handler - so the click opens the built-in composer instead.
|
||||
*
|
||||
* Deliberately still an anchor with a real href: right-click "Copy email
|
||||
* address", middle-click and assistive technology all keep working, and a
|
||||
* plain click is simply redirected. Modified clicks are left alone.
|
||||
*
|
||||
* Only for app chrome. Links inside message content are untrusted and keep
|
||||
* their existing behaviour.
|
||||
*/
|
||||
export function MailtoLink({ to, children, ...anchorProps }: MailtoLinkProps) {
|
||||
const href = to.toLowerCase().startsWith("mailto:") ? to : `mailto:${to}`;
|
||||
|
||||
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => {
|
||||
// Let the browser handle "open in new tab/window" style clicks.
|
||||
if (event.defaultPrevented || event.button !== 0) return;
|
||||
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
||||
if (!requestInternalMailto(href)) return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} {...anchorProps}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -116,3 +116,28 @@ export function parseMailto(raw: string): ParsedMailto | null {
|
||||
body: stripBodyControlChars(getQueryValue(searchParams, "body")).slice(0, MAX_BODY_LENGTH),
|
||||
};
|
||||
}
|
||||
|
||||
/** Window event carrying a parsed in-app mailto request to the mail page. */
|
||||
export const INTERNAL_MAILTO_EVENT = "bulwark:mailto";
|
||||
|
||||
/**
|
||||
* Ask the mail page to open its composer for an in-app `mailto:` click.
|
||||
* Loosely coupled through a window event (as `bulwark:rate-limit-blocked`
|
||||
* already is) because the composer state lives in the page component, several
|
||||
* levels above the address links in cards and sidebars.
|
||||
*
|
||||
* Returns whether the request was taken: false when the URL is unusable or no
|
||||
* listener is mounted, so callers can fall back to the browser's behaviour
|
||||
* rather than swallowing the click.
|
||||
*/
|
||||
export function requestInternalMailto(raw: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
const parsed = parseMailto(raw);
|
||||
if (!parsed || parsed.to.length === 0) return false;
|
||||
// The listener signals "handled" by cancelling the event, which makes
|
||||
// dispatchEvent report false - so an uncancelled dispatch means nobody took it.
|
||||
const uncancelled = window.dispatchEvent(
|
||||
new CustomEvent<ParsedMailto>(INTERNAL_MAILTO_EVENT, { detail: parsed, cancelable: true }),
|
||||
);
|
||||
return !uncancelled;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user