Merge pull request #737 from dealerweb/fix/internal-mailto-links

fix: open address links in the built-in composer
This commit is contained in:
Linus Rath
2026-08-05 17:20:18 +02:00
committed by GitHub
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;
}