Skip to content
Go back

Rails Removed Email Obfuscation. Here's How I Brought It Back with Stimulus

I recently needed to add a simple contact email to my footer. It sounds trivial: just use a mailto: link, right? But in 2024, putting a raw email address in your public HTML is basically an invitation for spam.

I remembered that Rails used to handle this for us. The mail_to helper had a handy encode: "hex" option that would scramble the email address to confuse harvesters. I went to the docs, ready to use it, only to find it was removed in Rails 4.0. The suggested alternative? “Install a gem.”

I didn’t want to add a dependency just to render one link. And frankly, the old JavaScript-based obfuscation techniques (like document.write or eval) are now blocked by modern Content Security Policies (CSP).

So I decided to build a modern solution. I wanted the best of both worlds:

  1. Zero Spam: The email address should effectively not exist in the DOM.
  2. Great UX: To the user, it should behave exactly like a normal link.

Here is how I solved it using Stimulus.

The goal became simple: The email address should not exist in the DOM until the user actually asks for it.

If a bot scans the source code, it should see nothing useful. If a human hovers over the icon, it should work instantly.

This is a perfect job for Stimulus.

The Approach

Instead of writing the email into the href, we split it up and hide it in data attributes:

<a href="#" 
   data-controller="mail-obfuscator" 
   data-mail-obfuscator-user-value="hello" 
   data-mail-obfuscator-domain-value="ayatflow.com">
   <!-- Icon -->
</a>

To a bot, this is a link to nowhere (#). It contains the strings “hello” and “ayatflow.com”, but without the @ or the mailto: context, it’s just noise.

The Magic (Stimulus Controller)

We need a controller that assembles these pieces only when the user interacts with the link.

// app/javascript/controllers/mail_obfuscator_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static values = { user: String, domain: String }

  connect() {
    // Ensure it's dead on arrival
    this.element.href = "#"
  }

  reveal() {
    // Assemble only when needed
    this.element.href = `mailto:${this.userValue}@${this.domainValue}`
  }

  reset() {
    // Cover your tracks
    this.element.href = "#"
  }
}

The Ruby Helper

To make this reusable, I wrapped it in a helper that feels just like the old mail_to:

# app/helpers/application_helper.rb
def obfuscated_mail_to(email, options = {}, &block)
  user, domain = email.split('@')
  
  options[:data] ||= {}
  options[:data].merge!(
    controller: "mail-obfuscator",
    mail_obfuscator_user_value: user,
    mail_obfuscator_domain_value: domain,
    # The secret sauce: Trigger on everything
    action: "mouseover->mail-obfuscator#reveal focus->mail-obfuscator#reveal mouseout->mail-obfuscator#reset blur->mail-obfuscator#reset click->mail-obfuscator#reveal"
  )
  options[:href] ||= "#"
  
  link_to(options[:href], options, &block)
end

The “Reset” Trick

You might notice the reset() action mapped to mouseout and blur.

This is my favorite part. As soon as your mouse leaves the link, it reverts back to href="#". If you try to “Right Click -> Copy Link Address” without hovering directly (or if you move your mouse away too fast), you get nothing.

It makes the window of opportunity for scraping extremely small, without impacting the UX for a genuine user who just wants to say hello.

Conclusion

Sometimes the best solutions aren’t about complex encryption, but just being a little bit slippery. This approach is lightweight, CSP-compliant, and keeps my inbox just a little bit cleaner.

Code is available in this Gist.

If this saved you time or helped you reason about a trade-off, feel free to reply on Twitter or email me .


Share this post on:

Previous Post
Constraint as a Feature: Designing an “Anti-Canvas” for Sacred Text
Next Post
Rails is the Brain, Go is the Photographer