Constructing mailto: Share Links with Encoded Bodies
This guide is part of SMS and Email Fallback Architectures, which itself is part of Permission Flows & Progressive Enhancement.
A mailto: link is the universal share fallback: every phone and desktop has a mail handler, so it works when navigator.share is absent and the clipboard is blocked. But a naive mailto:?subject=...&body=... breaks the moment the body contains an ampersand, a space at a parameter boundary, or a line break — the text silently truncates. This guide shows how to encode the subject and body correctly, insert portable CRLF line breaks, stay within the practical URL length limit, and trigger the mail client reliably from a click handler.
Feature Detection Gate
The mailto: path is a terminal fallback, so its gate is the inverse of the other options: route here only when native share and the clipboard are both unavailable. There is nothing to feature-detect on mailto: itself — it is a URL scheme every platform honours — so the gate lives in the caller:
export function shouldUseMailtoFallback() {
const hasShare =
window.isSecureContext === true && typeof navigator.share === 'function';
const hasClipboard =
window.isSecureContext === true &&
typeof navigator.clipboard?.writeText === 'function';
return !hasShare && !hasClipboard;
}
When both richer options exist, prefer them — navigator.share first, then the copy-to-clipboard fallback patterns. Reach for mailto: only when shouldUseMailtoFallback() returns true, or as the final branch after those paths throw.
Solution Walkthrough
Step 1 — Encode the Subject and Body Independently
Both the subject and the body must pass through encodeURIComponent. This turns spaces into %20, encodes ampersands so they cannot be mistaken for parameter separators, and preserves Unicode. Encoding each field separately — never the whole URL at once — is what keeps ? and & working as real delimiters:
export function buildMailtoUrl({ subject, body, to = '' }) {
const params = [
`subject=${encodeURIComponent(subject)}`,
`body=${encodeURIComponent(body)}`,
];
return `mailto:${encodeURIComponent(to)}?${params.join('&')}`;
}
The to is optional; leaving it empty produces mailto:?subject=... so the user picks the recipient. Encoding to as well keeps addresses with plus-tags or unusual characters intact.
Step 2 — Build the Body with Real Line Breaks
To get paragraph breaks in the composed email, put actual \r\n (CRLF) sequences in the string before encoding. encodeURIComponent('\r\n') produces %0D%0A, which mail clients render as a line break. Compose the body from an array of lines so the structure is explicit:
export function composeShareBody({ text, url }) {
const lines = [];
if (text) lines.push(text);
if (text && url) lines.push(''); // blank line -> paragraph gap
if (url) lines.push(url);
// Join with CRLF; encodeURIComponent (in buildMailtoUrl) makes it %0D%0A.
return lines.join('\r\n');
}
CRLF is the most portable choice. A bare \n (which encodes to %0A) works in many clients but not all, whereas %0D%0A is honoured broadly across desktop and mobile mail apps.
Step 3 — Budget the Length, Then Navigate
Very long mailto: URLs get truncated by the browser or the OS handler. Keep the whole encoded string under roughly 1800 characters — a conservative margin below the ~2000 where truncation becomes likely. If the body would blow the budget, share a short link to a hosted version instead of inlining everything:
const MAX_MAILTO_LENGTH = 1800;
export function openMailtoShare({ subject, text, url }) {
const body = composeShareBody({ text, url });
let href = buildMailtoUrl({ subject, body });
if (href.length > MAX_MAILTO_LENGTH) {
// Too long: drop the long text, keep the link so nothing truncates.
href = buildMailtoUrl({ subject, body: url ?? '' });
}
// location.href is the most reliable trigger inside a click handler.
window.location.href = href;
}
Assigning window.location.href inside the user’s click handler is the reliable way to open the mail client. This mirrors the SMS approach in building a resilient SMS fallback for unsupported browsers, where an sms: URI is triggered the same way.
Step 4 — Prefer location.href Over a Synthesized Anchor
If you render a real <a href="mailto:..."> in the DOM, the user clicking it works fine. What is unreliable is creating a detached anchor in JavaScript and calling .click() on it — some browsers block programmatic clicks on mailto: anchors. For a fallback triggered from your own share button, navigate directly:
export function attachMailtoFallback(button, getShareData) {
button.addEventListener('click', () => {
if (!shouldUseMailtoFallback()) return; // richer path handles it
const { subject, text, url } = getShareData();
openMailtoShare({ subject, text, url });
});
}
Because the navigation happens synchronously in the click handler, it stays inside the user gesture and the OS reliably surfaces the mail composer.
Failure Modes and Recovery
| Symptom | Root Cause | Minimal Fix |
|---|---|---|
Body truncates at an & or space |
Value not percent-encoded | Wrap subject and body in encodeURIComponent |
| Line breaks missing; text is one blob | Newlines absent or not encoded | Join lines with \r\n; let encoding produce %0D%0A |
| Whole email empty on open | Entire URL encoded once, breaking ?/& |
Encode each field separately, not the full URL |
| Mail client opens with clipped body | URL exceeds handler length limit | Cap near 1800 chars; substitute a short hosted link |
| Nothing happens on click | Programmatic .click() on a detached anchor blocked |
Use window.location.href = mailtoUrl instead |
| No app opens on desktop | No default mail client configured | Also offer copy-link so the user isn’t stranded |
Browser and Platform Caveat
The mailto: scheme is honoured universally, but what happens after navigation depends on the environment. On mobile, iOS opens Mail (or the user’s default) and Android opens the chosen mail app, both pre-filling subject and body from the encoded URL. On desktop, the OS opens whatever handler is registered — Outlook, Apple Mail, or a mailto: webmail handler — and if none is configured, nothing appears, which is why mailto: should sit alongside a copy-link option rather than being the sole fallback. Length tolerance also varies: mobile handlers are generally stricter, so the conservative 1800-character budget protects the worst case. Because it requires no permission, no secure context, and no API support, mailto: remains the dependable floor of the fallback chain described across the SMS and email fallback architectures section.
FAQ
Why is my mailto: body cut off at the first ampersand or space?
The body was not percent-encoded. An unencoded ampersand begins a new query parameter and a raw space can terminate URL parsing, so everything after it is discarded. Run encodeURIComponent on the body value before placing it after body=, and encode the subject the same way, so the full text survives.
How do I add line breaks to a mailto: body?
Put real CRLF newlines in the string and let encodeURIComponent convert them to %0D%0A. Build the body by joining an array of lines with '\r\n', then encode the whole string in the URL builder. Most clients render %0D%0A as a paragraph break; a bare %0A works in many but CRLF is the most portable.
Is there a length limit on a mailto: URL?
Practically, yes. Browsers and OS handlers truncate very long mailto: URLs, so keep the entire encoded string under roughly 1800 to 2000 characters. If the body is longer, share a short URL to a hosted version rather than embedding the full text, so nothing is silently clipped.
Should I use location.href or an anchor element for mailto:?
For a programmatic fallback fired inside a click handler, assigning window.location.href is the most reliable trigger. A rendered <a href="mailto:..."> that the user clicks directly is also fine. Avoid synthesizing a detached anchor and calling .click() on it — some browsers block that, whereas a location.href navigation to a mailto: URL is widely honoured.