feat(email): open external links in a new tab (safely)

External web links (http/https) in rendered email bodies open in a new browser
tab with target="_blank" rel="noopener noreferrer". mailto:, tel:, and in-page
#anchors keep their default behavior instead of spawning a blank tab.

The plaintext render path is already handled on main by #594 (ADD_URI_SAFE_ATTR
in PLAIN_TEXT_RENDERED_CONFIG), so this no longer adds its own hook there — the
plaintext linkifier only ever emits http(s) anchors, so the config's declarative
exemption is sufficient. This change covers the paths #594 did not:

- iframe HTML render: both anchor passes (the DOMPurify hook and the post-render
  DOM walk in email-viewer.tsx) set target=_blank on EVERY <a>, including
  mailto:/tel:. Now scoped to http(s) via the shared applyNewTabToAnchor()
  helper (http/https -> target+rel; mailto/tel/#/other -> strip target/rel).
- sanitizeI18nHtml: the same DOMPurify strip dropped target/rel from translated
  links (e.g. the docs link in settings.security.not_available, target="_blank"
  in 19/22 locales). Keep the author's target and harden rel="noopener
  noreferrer".

Tests: unit coverage for isHttpLinkHref / applyNewTabToAnchor / sanitizeI18nHtml
plus an integration suite over the real plaintext and HTML/iframe render
pipelines. The plaintext-hook-specific cases are dropped as redundant with #594.
This commit is contained in:
Stefan Hildebrandt
2026-07-21 20:59:20 +02:00
committed by Linus Rath
parent f51ec50443
commit d5017a211f
4 changed files with 234 additions and 11 deletions
+6 -10
View File
@@ -5,7 +5,7 @@ import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourcesOnNode, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
@@ -1662,10 +1662,8 @@ export function EmailViewer({
}
}
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
// http(s) links open in a new tab; other schemes keep their default.
applyNewTabToAnchor(node);
// No dark mode color transforms - emails render true-to-life in iframe
});
@@ -2280,11 +2278,9 @@ export function EmailViewer({
}
});
// Make links open in new tab
doc.querySelectorAll('a').forEach(a => {
a.setAttribute('target', '_blank');
a.setAttribute('rel', 'noopener noreferrer');
});
// Second pass over the rendered iframe DOM (the hook above only sees
// DOMPurify's output); http(s) → new tab, other schemes left in place.
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
// Plugin intercept: let plugins cancel or rewrite external links inside
// the email body before navigation happens. Bound on the iframe doc so