# Ahoy Email: first-party click and message analytics inside a Rails app

> Ahoy Email is a small Ruby gem that records sent email messages, tags links with UTM parameters and tracks clicks, storing results in your own database, Redis or a custom subscriber. It suits Rails teams that want campaign data to stay first party, and it is a poor fit for anyone who needs a hosted dashboard outside the application.

**ankane/ahoy_email** — First-party email analytics for Rails

- Repository: https://github.com/ankane/ahoy_email
- Stars: 1,195 · Forks: 144
- Language: Ruby
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/ankane-ahoy-email

## Three features that stand on their own, not one analytics suite

Ahoy Email is a Ruby gem, MIT licensed, that adds email analytics to a Rails application without sending data anywhere outside it. The README splits the gem into three features that can be used independently: message history, which records that a mail was sent, UTM tagging, which decorates links so traffic can be attributed to a campaign, and click analytics, which records that a link in a message was followed. The independence is the point. A team that only wants campaign parameters on its links can add utm_params and touch nothing else, while a team that wants a durable record of every message sent to every user can add has_history without enabling click tracking at all. There is no bundled dashboard, no hosted service and no JavaScript tracker, because the README points the analytics question at a different gem for web and native apps and the subscription management question at another one.

## Message history lands in your schema, encrypted or not

The message history feature is implemented by a generated migration that creates the ahoy_messages table, and the generator takes an explicit encryption flag. With --encryption=lockbox, email addresses are encrypted using Lockbox and Blind Index, two separate gems the README asks you to install. With --encryption=activerecord, the encryption comes from Active Record encryption instead. With --encryption=none, the addresses are stored as plain text. The choice is made at generation time and then baked into the table, which is the first decision worth getting right, because changing it later means a data migration that the README does not describe. A mailer opts in with the has_history class method, and the model is queried through Ahoy::Message. Attachments to the history are limited per action with only and except, and a global default can be set so every mailer is tracked without editing each one.

The generator is the first command you run, and the encryption flag decides how addresses are stored.

```sh
rails generate ahoy:messages --encryption=lockbox
rails db:migrate
```

For plain Active Record encryption, swap the flag for --encryption=activerecord, or for --encryption=none if you do not want the addresses encrypted at all. Whichever you choose, the migration creates the table in your own database, and the mailer opts in like this.

```ruby
class CouponMailer < ApplicationMailer
  has_history
end
```

After that, the messages are queryable through the model.

```ruby
Ahoy::Message.last
```

## The user association is polymorphic and resolved by three guesses

Linking a message to a user takes a polymorphic association, so the same gem works with any model you own, not just a class named User. The lookup is automatic and happens in a fixed order: the gem tries @user, then params[:user], then a User.find_by(email: message.to) lookup. That third fallback is the interesting part, because it is how a message sent to a raw address still gets attached to a record, and it is also a source of surprising behaviour when two models share an email column. The README offers an explicit override with a lambda for cases where the automatic guess picks the wrong record, and an association on your own model so that user.messages returns everything sent to that person. Extra data can be attached to a message as well, either as a literal hash or a proc, which requires adding your own column through a migration before it can be stored.

## UTM tagging writes three parameters, and links can opt out

Adding the utm_params macro to a mailer decorates the links in that mailer with campaign parameters so a visit can be traced back to the email. The defaults follow a predictable scheme: utm_medium is email, utm_source is the mailer name such as coupon_mailer, and utm_campaign is the mailer action such as offer. Each can be replaced, including the campaign, which can be a proc when it depends on request data such as a coupon id. The same only and except mechanism used for message history limits tagging to particular actions, and a link can be excluded from tagging individually through a data attribute named skip_utm_params on the link helper. The README also documents a url_options setting for the host used to build absolute links, which matters for a mailer generating URLs outside a request cycle, and a separate option that controls whether unsubscribe links are excluded from click tracking by default.

Tagging a mailer is a one-line change, and the defaults fill in medium, source and campaign from the mailer name and action.

```ruby
class CouponMailer < ApplicationMailer
  utm_params
end
```

Override the campaign when it depends on request data.

```ruby
class CouponMailer < ApplicationMailer
  utm_params utm_campaign: -> { "coupon#{params[:coupon].id}" }
end
```

## Clicks are counted through subscribers you choose

Click tracking is a mailer macro plus a subscriber. The macro takes a campaign name, which can be a literal string or a proc, and it can be limited to certain actions or made conditional on a lambda such as an opt-in check. Where the counts land is entirely up to you. The built-in DatabaseSubscriber records them in a table created by rails generate ahoy:clicks. The RedisSubscriber takes a Redis client, so you can pass your own instance. A third path lets you write a plain Ruby class with track_send, track_click and an optional stats method, register it as a subscriber, and implement the storage yourself. The README notes that a mailer storing stats in the database should also use has_history, which is a coupling worth understanding: the click rows and the message rows are separate records that the gem does not join for you. The api option is set to true in each configuration, and AhoyEmail.stats returns figures for a named campaign.

The database path starts with its own generator and migration.

```sh
rails generate ahoy:clicks
rails db:migrate
```

Then the subscriber is registered in an initializer.

```ruby
AhoyEmail.subscribers << AhoyEmail::DatabaseSubscriber
AhoyEmail.api = true
```

And the mailer declares which campaign the clicks belong to.

```ruby
class CouponMailer < ApplicationMailer
  track_clicks campaign: "my-campaign"
end
```

## Retention is a query you run yourself

Ahoy Email does not include a scheduled cleanup task. The README gives you the queries and leaves the scheduling to your application or a background job. Deleting old rows in batches is a single scoped call against the message model using the sent_at column and an in_batches delete_all, which keeps the delete from holding a long lock on a large table. The same pattern works for a single user, filtering on user_id and user_type so the polymorphic association is disambiguated. If you want this to happen on a schedule, you have to add that scheduler to your Rails app, because the gem itself ships no rake task for it. That is a small gap, but on an application sending a high volume of mail it is the difference between a table that stays small and one that grows until a retention policy is bolted on later.

## The 4.0 upgrade carries a Rails hash digest change with it

The README states that Ahoy Email 4.0 was recently released, and the upgrade section carries a specific warning. Versions 2.3.0 through 3.0.0 caused applications to use SHA1 for the key generator hash digest class, and this follows a change in Rails behaviour rather than a choice in the gem. To avoid breakage when upgrading, the README asks you to add a specific line to config/application.rb that sets the digest class to OpenSSL::Digest::SHA1. The repository publishes no GitHub releases, so the changelog file in the repository is the place to read what else changed between versions. The last push was on 2026-09-21, and the gem carries an MIT licence, so there is no commercial restriction on using it in a proprietary application.

## Conclusion

Ahoy Email is worth adopting when the app is already Rails, the team wants click and message history stored under its own database schema, and a reporting surface will be built in house. It does not fit a stack outside Rails, a team expecting a ready-made analytics dashboard, or anyone who needs data retention handled by the gem itself, since deletion is a manual SQL call. Before adding it, decide on the encryption mode for ahoy_messages and run the corresponding rails generate ahoy:messages migration first, because the three choices of lockbox, activerecord and none are not interchangeable once rows exist.

## FAQ

### How do I install Ahoy Email in a Rails app?

Add the gem to your Gemfile and run bundle install. The README shows the line to add, gem "ahoy_email", and the gem works as a normal Ruby dependency with no external service to sign up for.

### What is the difference between lockbox, activerecord and none encryption?

They are three values for the --encryption flag on the rails generate ahoy:messages generator. lockbox encrypts addresses with the Lockbox and Blind Index gems, activerecord uses Active Record encryption, and none stores them as plain text.

### Where can Ahoy Email store click statistics?

The README documents three options: the DatabaseSubscriber with a table from rails generate ahoy:clicks, the RedisSubscriber with a Redis client, or a custom class of your own implementing track_send, track_click and optionally stats, registered through AhoyEmail.subscribers.

### Does Ahoy Email clean up old message records automatically?

No. The README shows how to delete older data with a scoped query on Ahoy::Message using sent_at and in_batches, but scheduling that query is left to the application.

## Sources

- [ankane/ahoy_email on GitHub](https://github.com/ankane/ahoy_email)
- [Issues](https://github.com/ankane/ahoy_email/issues)
- [License: MIT](https://github.com/ankane/ahoy_email/blob/master/LICENSE)
- [README](https://github.com/ankane/ahoy_email/blob/master/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/ankane-ahoy-email
