Skip to content

Isopach/mailhider

Repository files navigation

mailhider

Hide email addresses from scrapers without breaking the mailto: link for humans. ~1 KB, zero runtime dependencies, works in any HTML page.

npm bundle size license

Live demo → · npm · Report a bug

Put a mailto: link on a public page and a scraper will harvest it within hours. mailhider stores the address reversed in data-* attributes and reassembles it client-side, so a regex over your HTML source finds nothing while real visitors get a normal clickable link. Optional click-to-reveal mode raises the cost further by withholding the address until a real user interacts with the element.

It works three ways: a <script> tag for static HTML, an ES module for build steps, and a CLI for one-off snippets.

Table of Contents


Install

npm i mailhider

Or skip the install and load the runtime from a CDN — see Quick start.


Quick start

Drop the obfuscated span into your HTML and include the decoder script once anywhere on the page.

<span class="mh-email" data-u="olleh" data-d="moc.elpmaxe">
  <noscript>Enable JavaScript to view email.</noscript>
</span>

<script src="https://unpkg.com/mailhider/dist/browser.js"></script>

data-u is the username (hello) reversed, data-d is the domain (example.com) reversed. The decoder reverses them at runtime and replaces the span with <a href="mailto:hello@example.com">hello@example.com</a>.

To generate the snippet from an email address, use the CLI or obfuscate().


Threat model — what this actually stops

Email obfuscation is a cost-raising measure, not a cryptographic one. Here is what mailhider actually does against the scrapers in the wild:

Scraper type Reverse mode (default) Click-to-reveal mode
curl + regex ✅ defeated ✅ defeated
wget mirroring ✅ defeated ✅ defeated
Generic HTML-only crawlers (the vast majority) ✅ defeated ✅ defeated
Headless Chrome / Playwright with no interaction ✅ defeated ✅ defeated
Headless browser that clicks every span on every page ❌ readable from DOM ✅ defeated (no click triggered programmatically)
Targeted human attacker reading your source code ❌ trivially reversed ❌ trivially reversed

Reverse mode is the right default. It stops the 95%+ of scrapers that don't execute JavaScript, and it stops headless browsers that just render the page without clicking. Click mode raises the cost further at the price of one user interaction.

What this library does not do:

  • Defeat a targeted attacker. The decoder is open source and the encoding is reversible by design.
  • Cover off-page exposure. If your address still appears in feed.xml, JSON-LD blocks, sitemap.xml, or Open Graph tags, scrapers will find it there. Remove or blank those at the source.
  • Cover image-rendered or PDF-attached emails. For maximum security, render the address as an image server-side.

If overclaim makes you nervous, the live demo runs a regex scan over its own source bytes so you can see exactly what a naive scraper sees.


Compared to other approaches

Approach Bundle No-JS readable Stops headless browsers Notes
Plain mailto: link 0 B Harvested within hours.
HTML entity encoding (&#104;...) 0 B Defeated by any scraper from the last decade.
[at] / [dot] text obfuscation 0 B Matched by common scraper regex variants.
Cloudflare Email Protection ~1 KB inline Cloudflare-only, breaks outside their proxy.
mailhider reverse mode ~1 KB optional Works anywhere static HTML works.
mailhider click mode ~1 KB optional ✅ (stronger) One extra user click in exchange for more cost to bots.
Server-rendered image of the email ~2 KB image Inaccessible. No mailto:. Bad UX.
Contact form (no email shown) varies Different UX trade-off; complementary, not a replacement.

How to hide

Build-time encoding (Node)

Generate the obfuscated HTML from JavaScript or TypeScript.

import { obfuscate } from 'mailhider';

const html = obfuscate('hello@example.com');

// <span class="mh-email" data-u="olleh" data-d="moc.elpmaxe"><noscript>Enable JavaScript to view email.</noscript></span>

Use the output anywhere you'd write static HTML: server-rendered templates, static site generators, build scripts.


Click-to-reveal mode

The email is not assembled until the visitor clicks. This defeats non-interactive JS-executing scrapers (headless Chrome, Playwright) because they don't simulate clicks on every span on every page they crawl. A scraper that does click every span on every page can still extract it — see the threat-model table above.

const html = obfuscate('hello@example.com', { mode: 'click' });

// <span class="mh-email" data-u="olleh" data-d="moc.elpmaxe" data-mh-mode="click">Show email</span>

The default label in click mode is Show email. Override with the label option. After the click, the runtime assembles the address, replaces the span with a real anchor, and immediately triggers it — so the visitor's mail client opens with one click, not two.


Custom label

The visible text shown to humans before the JS-decoded address replaces it.

const html = obfuscate('hello@example.com', { label: 'Email us' });

// <span class="mh-email" data-u="olleh" data-d="moc.elpmaxe">Email us</span>

mailto subject

Pre-fill the subject line of the assembled mailto: link.

const html = obfuscate('sales@example.com', { subject: 'Pricing inquiry' });

// <span class="mh-email" data-u="selas" data-d="moc.elpmaxe" data-mh-subject="Pricing inquiry"><noscript>Enable JavaScript to view email.</noscript></span>

The runtime decoder assembles mailto:sales@example.com?subject=Pricing%20inquiry.


Bracket fallback (no-JS friendly)

With bracketFallback: true, the visible text uses the user[at]domain[dot]tld form instead of an empty <noscript> fallback. This lets no-JS visitors read the address — at the cost of being readable by scrapers that match [at] / [dot] variants. Only use this if no-JS reachability matters to you more than scraper resistance.

const html = obfuscate('info@example.com', { bracketFallback: true });

// <span class="mh-email" data-u="ofni" data-d="moc.elpmaxe">info[at]example[dot]com</span>

Pre-existing anchor

To preserve existing button or link styling, write the anchor yourself and let the decoder fill in the href. Use a <span class="mh-email-display"> slot to control where the assembled address text goes.

<a class="mh-email button" data-u="olleh" data-d="moc.elpmaxe">
  <span>Contact →</span>
  <span class="mh-email-display"></span>
</a>

The decoder sets the anchor's href to mailto:hello@example.com and fills the .mh-email-display span with the assembled address.


Custom CSS class

If mh-email conflicts with existing styles, change the wrapper class.

const html = obfuscate('hello@example.com', { className: 'my-email' });

// <span class="my-email" data-u="olleh" data-d="moc.elpmaxe">...

Pass the matching class to hydrate(root, className) at runtime.


CLI

npx mailhider <email> [options]

Options:
  --mode=reverse|click    Obfuscation mode (default: reverse)
  --label=TEXT            Visible text (default: empty span with noscript fallback)
  --bracket-fallback      Use user[at]domain[dot]tld as the visible text
  --subject=TEXT          mailto subject line
  --class=NAME            CSS class on the wrapper (default: mh-email)
  --script                Also print the runtime <script> tag
  -h, --help              Show help
  -V, --version           Show version

Examples:

npx mailhider hello@example.com
npx mailhider support@example.com --mode=click --label="Email support"
npx mailhider sales@yourbusiness.com --subject="Pricing inquiry" --script
npx mailhider info@example.com --bracket-fallback

API reference

import { obfuscate, decode, hydrate, runtimeScript } from 'mailhider';

obfuscate(email: string, options?: ObfuscateOptions): string;
decode(html: string): string | null;
hydrate(root?: Document | HTMLElement, className?: string): number;
runtimeScript: string;

obfuscate(email, options)

Returns the obfuscated HTML snippet.

interface ObfuscateOptions {
  mode?: 'reverse' | 'click';   // Default: 'reverse'
  label?: string;                // Visible text. Default: empty + <noscript>
  bracketFallback?: boolean;     // Use user[at]domain[dot]tld as the visible text
  subject?: string;              // mailto subject
  className?: string;            // Default: 'mh-email'
}

decode(html)

Recovers the email from an obfuscated snippet. Mostly useful for tests. Returns null if no decodable span is found.

decode(obfuscate('hello@example.com'));
// 'hello@example.com'

hydrate(root, className)

Decodes every matching element under root and returns the count decoded. Default root is document, default className is mh-email. Idempotent — re-running is a no-op for already-decoded elements.

runtimeScript

The decoder runtime as a vanilla-JS IIFE string. Drop into a <script> tag for sites without a build step.


Framework recipes

Jekyll

<!-- _includes/email.html -->
<span class="mh-email" data-u="{{ include.u }}" data-d="{{ include.d }}">
  <noscript>Enable JavaScript to view email.</noscript>
</span>

Add the decoder once in your layout:

<script src="https://unpkg.com/mailhider/dist/browser.js"></script>

Hugo

{{/* layouts/shortcodes/email.html */}}
<span class="mh-email" data-u="{{ .Get "u" }}" data-d="{{ .Get "d" }}">
  <noscript>Enable JavaScript to view email.</noscript>
</span>
{{< email u="olleh" d="moc.elpmaxe" >}}

Astro, 11ty, Next.js

obfuscate() is a plain function. Call it in any render path and output the returned HTML.

import { obfuscate } from 'mailhider';

const html = obfuscate('hello@example.com');

SPA navigation support

The decoder re-runs automatically on these client-side navigation events:

  • Hydejack: hy-push-state-load
  • Astro: astro:page-load
  • Turbo (Rails): turbo:load

For frameworks not in the list, call hydrate() yourself after navigation.


Release history

0.2.0

  • Fix mailhider/runtime subpath import on CommonJS consumers (Node 18–20). Previously require('mailhider/runtime') would throw ERR_REQUIRE_ESM.
  • Conditional types resolution across all subpath exports so moduleResolution: "nodenext" projects pick the correct .d.ts / .d.cts for their module system.
  • Stricter email validation in obfuscate(). Inputs with whitespace, newlines, control characters, multiple @ signs, or missing TLD now throw with a clear error. The previous validation only checked for one non-edge @.
  • hydrate() now uses getElementsByClassName instead of querySelectorAll('.${className}'), so arbitrary class strings (including those with CSS metacharacters) no longer break selector parsing.
  • Ship readable, unminified source in the npm tarball. Following npm best practice, consumers' bundlers handle minification downstream; the unminified source keeps the package auditable by security tools and humans reading node_modules/. End-user bundles are unaffected.
  • Removed the CDN URL from src/browser.ts JSDoc to keep external URL strings out of shipped JavaScript. CDN usage is documented in the README only.
  • Disabled source-map generation in shipped builds. Source-map comments in the dist files previously referenced .map files that were never published.
  • Tightened README and demo wording: replaced "defeats most JS-executing scrapers" with "defeats non-interactive JS-executing scrapers" to be precise about the threat model.

0.1.0

  • Initial release
  • obfuscate() build-time encoder
  • hydrate() runtime decoder
  • Click-to-reveal mode
  • CLI with --mode, --label, --subject, --bracket-fallback, --class, --script
  • Auto-rehydration on Hydejack hy-push-state-load, Astro astro:page-load, Turbo turbo:load

License

MIT © Koh You Liang

About

A tiny (~700 bytes), zero-dependency E-mail hider that counters page-source scrapers. Humans can still read, copy, and click the real address. Noscript fallback for JS-disabled users. Optional click-to-reveal mode also blocks JS-based scrapers.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors