From 04996a3af1f9dd28af8f6d0b6a276eb01351f90a Mon Sep 17 00:00:00 2001 From: dealerweb Date: Wed, 5 Aug 2026 14:40:09 +0200 Subject: [PATCH] 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. --- app/(main)/[locale]/page.tsx | 21 ++++- components/contacts/contact-detail.tsx | 11 +-- components/email/email-viewer.tsx | 11 +-- components/email/recipient-popover.tsx | 7 +- components/ui/__tests__/mailto-link.test.tsx | 89 ++++++++++++++++++++ components/ui/mailto-link.tsx | 41 +++++++++ lib/protocol-handlers/mailto.ts | 25 ++++++ 7 files changed, 191 insertions(+), 14 deletions(-) create mode 100644 components/ui/__tests__/mailto-link.test.tsx create mode 100644 components/ui/mailto-link.tsx diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index 5f03ea1d7..ad703c93e 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -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).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 diff --git a/components/contacts/contact-detail.tsx b/components/contacts/contact-detail.tsx index 1b07d86e2..b520c23c3 100644 --- a/components/contacts/contact-detail.tsx +++ b/components/contacts/contact-detail.tsx @@ -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) => (
- + {e.address} - + - - +
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index bbdc0a76b..4b3c3d1c7 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 */}
- {t('contact_sidebar.action_email')} - + - Email - + {onViewContact && (