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:
dealerweb
2026-08-05 14:42:24 +02:00
parent e94a1429d5
commit 04996a3af1
7 changed files with 191 additions and 14 deletions
+20 -1
View File
@@ -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
+6 -5
View File
@@ -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>
+6 -5
View File
@@ -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"
+4 -3
View File
@@ -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);
});
});
+41
View File
@@ -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>
);
}
+25
View File
@@ -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;
}