pax_global_header00006660000000000000000000000064140210011410014472gustar00rootroot0000000000000052 comment=81f9b097e1126c0403197acd1cd8595f98aa5244 ahoy_email-1.1.1/000077500000000000000000000000001402100114100136015ustar00rootroot00000000000000ahoy_email-1.1.1/.github/000077500000000000000000000000001402100114100151415ustar00rootroot00000000000000ahoy_email-1.1.1/.github/ISSUE_TEMPLATE.md000066400000000000000000000002231402100114100176430ustar00rootroot00000000000000Hi, Before creating an issue, please check out the Contributing Guide: https://github.com/ankane/ahoy_email/blob/master/CONTRIBUTING.md Thanks! ahoy_email-1.1.1/.github/workflows/000077500000000000000000000000001402100114100171765ustar00rootroot00000000000000ahoy_email-1.1.1/.github/workflows/build.yml000066400000000000000000000020101402100114100210110ustar00rootroot00000000000000name: build on: [push, pull_request] jobs: build: if: "!contains(github.event.head_commit.message, '[skip ci]')" runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - ruby: 3.0 gemfile: Gemfile - ruby: 2.7 gemfile: gemfiles/actionmailer60.gemfile - ruby: 2.6 gemfile: gemfiles/actionmailer52.gemfile - ruby: 2.5 gemfile: gemfiles/actionmailer51.gemfile - ruby: 2.4 gemfile: gemfiles/actionmailer50.gemfile - ruby: 2.7 gemfile: gemfiles/mongoid7.gemfile mongodb: true - ruby: 2.6 gemfile: gemfiles/mongoid6.gemfile mongodb: true env: BUNDLE_GEMFILE: ${{ matrix.gemfile }} steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} bundler-cache: true - if: ${{ matrix.mongodb }} uses: ankane/setup-mongodb@v1 - run: bundle exec rake test ahoy_email-1.1.1/.gitignore000066400000000000000000000003021402100114100155640ustar00rootroot00000000000000*.gem *.rbc .bundle .config .yardoc *.lock InstalledFiles _yardoc coverage doc/ lib/bundler/man pkg rdoc spec/reports test/tmp test/version_tmp tmp *.bundle *.so *.o *.a mkmf.log *.log *.sqlite ahoy_email-1.1.1/CHANGELOG.md000066400000000000000000000051411402100114100154130ustar00rootroot00000000000000## 1.1.1 (2021-03-06) - Added support for classes for subscribers - Use `datetime` type in migration ## 1.1.0 (2019-07-15) - Made `opened_at` optional with click tracking - Fixed secret token for environment variables - Removed support for Rails 4.2 ## 1.0.3 (2019-02-18) - Fixed custom message model - Fixed `message` option with proc ## 1.0.2 (2018-10-02) - Fixed error with Ruby < 2.5 - Fixed UTM parameters storage on model ## 1.0.1 (2018-09-27) - Use observer instead of interceptor ## 1.0.0 (2018-09-27) - Removed support for Rails < 4.2 Breaking changes - UTM tagging, open tracking, and click tracking are no longer enabled by default - Only sent emails are recorded - Proc options are now executed in the context of the mailer and take no arguments - Invalid options now throw an `ArgumentError` - `AhoyEmail.track` was removed in favor of `AhoyEmail.default_options` - The `heuristic_parse` option was removed and is now the default ## 0.5.2 (2018-04-26) - Fixed secret token for Rails 5.2 - Added `heuristic_parse` option ## 0.5.1 (2018-04-19) - Fixed deprecation warning in Rails 5.2 - Added `unsubscribe_links` option - Allow `message_model` to accept a proc - Use `references` in migration ## 0.5.0 (2017-05-01) - Added support for Rails 5.1 - Added `invalid_redirect_url` ## 0.4.0 (2016-09-01) - Fixed `belongs_to` error in Rails 5 - Include `safely_block` gem without polluting global namespace ## 0.3.2 (2016-07-27) - Fixed deprecation warning for Rails 5 - Do not track content by default on fresh installations ## 0.3.1 (2016-05-11) - Fixed deprecation warnings - Fixed `stack level too deep` error ## 0.3.0 (2015-12-16) - Added safely for error reporting - Fixed error with `to` - Prevent duplicate records when mail called multiple times ## 0.2.4 (2015-07-29) - Added `extra` option for extra attributes ## 0.2.3 (2015-03-22) - Save utm parameters - Added `url_options` - Skip tracking for `mailto` links - Only set secret token if not already set ## 0.2.2 (2014-08-31) - Fixed secret token for Rails 4.1 - Fixed links with href - Fixed message id for Rails 3.1 ## 0.2.1 (2014-05-26) - Added `only` and `except` options ## 0.2.0 (2014-05-10) - Enable tracking when track is called by default ## 0.1.5 (2014-05-09) - Rails 3 fix ## 0.1.4 (2014-05-04) - Try not to rewrite unsubscribe links ## 0.1.3 (2014-05-03) - Added `to` and `mailer` fields - Added subscribers for open and click events ## 0.1.2 (2014-05-01) - Added `AhoyEmail.track` (fix) ## 0.1.1 (2014-04-30) - Use secure compare for signature verification - Fixed deprecation warnings ## 0.1.0 (2014-04-29) - First major release ahoy_email-1.1.1/CONTRIBUTING.md000066400000000000000000000026671402100114100160450ustar00rootroot00000000000000# Contributing First, thanks for wanting to contribute. You’re awesome! :heart: ## Help We’re not able to provide support through GitHub Issues. If you’re looking for help with your code, try posting on [Stack Overflow](https://stackoverflow.com/). All features should be documented. If you don’t see a feature in the docs, assume it doesn’t exist. ## Bugs Think you’ve discovered a bug? 1. Search existing issues to see if it’s been reported. 2. Try the `master` branch to make sure it hasn’t been fixed. ```rb gem "ahoy_email", github: "ankane/ahoy_email" ``` If the above steps don’t help, create an issue. Include: - Detailed steps to reproduce - Complete backtraces for exceptions ## New Features If you’d like to discuss a new feature, create an issue and start the title with `[Idea]`. ## Pull Requests Fork the project and create a pull request. A few tips: - Keep changes to a minimum. If you have multiple features or fixes, submit multiple pull requests. - Follow the existing style. The code should read like it’s written by a single person. - Add one or more tests if possible. Make sure existing tests pass with: ```sh bundle exec rake test ``` Feel free to open an issue to get feedback on your idea before spending too much time on it. --- This contributing guide is released under [CCO](https://creativecommons.org/publicdomain/zero/1.0/) (public domain). Use it for your own project without attribution. ahoy_email-1.1.1/Gemfile000066400000000000000000000001461402100114100150750ustar00rootroot00000000000000source "https://rubygems.org" gemspec gem "actionmailer", "~> 6.1.0" gem "activerecord", "~> 6.1.0" ahoy_email-1.1.1/LICENSE.txt000066400000000000000000000020611402100114100154230ustar00rootroot00000000000000Copyright (c) 2014-2021 Andrew Kane MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ahoy_email-1.1.1/README.md000066400000000000000000000176371402100114100150760ustar00rootroot00000000000000# Ahoy Email First-party email analytics for Rails :fire: For web and native app analytics, check out [Ahoy](https://github.com/ankane/ahoy) :bullettrain_side: To manage unsubscribes, check out [Mailkick](https://github.com/ankane/mailkick) [![Build Status](https://github.com/ankane/ahoy_email/workflows/build/badge.svg?branch=master)](https://github.com/ankane/ahoy_email/actions) ## Installation Add this line to your application’s Gemfile: ```ruby gem 'ahoy_email' ``` And run the generator. This creates a model to store messages. ```sh rails generate ahoy_email:install rails db:migrate ``` ## Getting Started There are three main features: - [Message history](#message-history) - [UTM tagging](#utm-tagging) - [Open & click analytics](#open--click-analytics) ## Message History Ahoy Email creates an `Ahoy::Message` record for each email sent by default. You can disable history for a mailer: ```ruby class CouponMailer < ApplicationMailer track message: false # use only/except to limit actions end ``` Or by default: ```ruby AhoyEmail.default_options[:message] = false ``` ### Users Ahoy Email records the user a message is sent to - not just the email address. This gives you a history of messages for each user, even if they change addresses. By default, Ahoy tries `@user` then `params[:user]` then `User.find_by(email: message.to)` to find the user. You can pass a specific user with: ```ruby class CouponMailer < ApplicationMailer track user: -> { params[:some_user] } end ``` The user association is [polymorphic](https://railscasts.com/episodes/154-polymorphic-association), so use it with any model. To get all messages sent to a user, add an association: ```ruby class User < ApplicationRecord has_many :messages, class_name: "Ahoy::Message", as: :user end ``` And run: ```ruby user.messages ``` ### Extra Attributes Record extra attributes on the `Ahoy::Message` model. Create a migration to add extra attributes to the `ahoy_messages` table. For example: ```ruby class AddCouponIdToAhoyMessages < ActiveRecord::Migration[6.1] def change add_column :ahoy_messages, :coupon_id, :integer end end ``` Then use: ```ruby class CouponMailer < ApplicationMailer track extra: {coupon_id: 1} end ``` You can use a proc as well. ```ruby class CouponMailer < ApplicationMailer track extra: -> { {coupon_id: params[:coupon].id} } end ``` ## UTM Tagging Use UTM tagging to attribute a conversion (like an order) to an email campaign. If you use [Ahoy](https://github.com/ankane/ahoy) for web analytics: 1. Send an email with UTM parameters 2. When a user visits the site, Ahoy will create a visit with the UTM parameters 3. When a user orders, the visit will be associated with the order (if [configured](https://github.com/ankane/ahoy#associated-models)) Add UTM parameters to links with: ```ruby class CouponMailer < ApplicationMailer track utm_params: true # use only/except to limit actions end ``` The defaults are: - `utm_medium` - `email` - `utm_source` - the mailer name like `coupon_mailer` - `utm_campaign` - the mailer action like `offer` You can customize them with: ```ruby class CouponMailer < ApplicationMailer track utm_params: true, utm_campaign: -> { "coupon#{params[:coupon].id}" } end ``` Skip specific links with: ```erb <%= link_to "Go", some_url, data: {skip_utm_params: true} %> ``` ## Open & Click Analytics While it’s nice to get feedback on the performance of your emails, we discourage the use of open tracking. If you do decide to use open or click tracking, be sure to get consent from your users and consider a short retention period. Check out [this article](https://www.eff.org/deeplinks/2019/01/stop-tracking-my-emails) for more best practices. ### Setup Create a migration with: ```ruby class AddTokenToAhoyMessages < ActiveRecord::Migration[6.1] def change add_column :ahoy_messages, :token, :string add_index :ahoy_messages, :token # for opens add_column :ahoy_messages, :opened_at, :timestamp # for clicks add_column :ahoy_messages, :clicked_at, :timestamp end end ``` Create an initializer `config/initializers/ahoy_email.rb` with: ```ruby AhoyEmail.api = true ``` And add to mailers you want to track: ```ruby class CouponMailer < ApplicationMailer track open: true, click: true end ``` Use only and except to limit actions ```ruby class CouponMailer < ApplicationMailer track click: true, only: [:welcome] end ``` Or make it conditional ```ruby class CouponMailer < ApplicationMailer track click: -> { params[:user].opted_in? } end ``` ### How It Works For opens, an invisible pixel is added right before the `` tag in HTML emails. If the recipient has images enabled in their email client, the pixel is loaded and the open time recorded. For clicks, a redirect is added to links to track clicks in HTML emails. ``` https://chartkick.com ``` becomes ``` https://yoursite.com/ahoy/messages/rAnDoMtOkEn/click?url=https%3A%2F%2Fchartkick.com&signature=... ``` A signature is added to prevent [open redirects](https://www.owasp.org/index.php/Open_redirect). Skip specific links with: ```erb <%= link_to "Go", some_url, data: {skip_click: true} %> ``` By default, unsubscribe links are excluded. To change this, use: ```ruby AhoyEmail.default_options[:unsubscribe_links] = true ``` You can specify the domain to use with: ```ruby AhoyEmail.default_options[:url_options] = {host: "mydomain.com"} ``` ### Events Subscribe to open and click events by adding to the initializer: ```ruby class EmailSubscriber def open(event) # your code end def click(event) # your code end end AhoyEmail.subscribers << EmailSubscriber.new ``` Here’s an example if you use [Ahoy](https://github.com/ankane/ahoy) to track visits and events: ```ruby class EmailSubscriber def open(event) event[:controller].ahoy.track "Email opened", message_id: event[:message].id end def click(event) event[:controller].ahoy.track "Email clicked", message_id: event[:message].id, url: event[:url] end end AhoyEmail.subscribers << EmailSubscriber.new ``` ## Data Protection We recommend encrypting the `to` field (as well as the `subject` if it’s sensitive). [Lockbox](https://github.com/ankane/lockbox) is great for this. Use [Blind Index](https://github.com/ankane/blind_index) if you need to query by the `to` field. Create `app/models/ahoy/message.rb` with: ```ruby class Ahoy::Message < ApplicationRecord self.table_name = "ahoy_messages" belongs_to :user, polymorphic: true, optional: true encrypts :to blind_index :to end ``` ## Data Retention Delete older data with: ```ruby Ahoy::Message.where("created_at < ?", 1.year.ago).in_batches.delete_all ``` Delete data for a specific user with: ```ruby Ahoy::Message.where(user_id: 1).in_batches.delete_all ``` ## Reference Set global options ```ruby AhoyEmail.default_options[:user] = -> { params[:admin] } ``` Use a different model ```ruby AhoyEmail.message_model = -> { UserMessage } ``` Or fully customize how messages are tracked ```ruby AhoyEmail.track_method = lambda do |data| # your code end ``` ## Mongoid If you prefer to use Mongoid instead of Active Record, create `app/models/ahoy/message.rb` with: ```ruby class Ahoy::Message include Mongoid::Document belongs_to :user, polymorphic: true, optional: true, index: true field :to, type: String field :mailer, type: String field :subject, type: String field :sent_at, type: Time end ``` ## History View the [changelog](https://github.com/ankane/ahoy_email/blob/master/CHANGELOG.md) ## Contributing Everyone is encouraged to help improve this project. Here are a few ways you can help: - [Report bugs](https://github.com/ankane/ahoy_email/issues) - Fix bugs and [submit pull requests](https://github.com/ankane/ahoy_email/pulls) - Write, clarify, or fix documentation - Suggest or add new features To get started with development: ```sh git clone https://github.com/ankane/ahoy_email.git cd ahoy_email bundle install bundle exec rake test ``` ahoy_email-1.1.1/Rakefile000066400000000000000000000002601402100114100152440ustar00rootroot00000000000000require "bundler/gem_tasks" require "rake/testtask" task default: :test Rake::TestTask.new do |t| t.libs << "test" t.pattern = "test/**/*_test.rb" t.warning = false end ahoy_email-1.1.1/ahoy_email.gemspec000066400000000000000000000017211402100114100172560ustar00rootroot00000000000000require_relative "lib/ahoy_email/version" Gem::Specification.new do |spec| spec.name = "ahoy_email" spec.version = AhoyEmail::VERSION spec.summary = "First-party email analytics for Rails" spec.homepage = "https://github.com/ankane/ahoy_email" spec.license = "MIT" spec.author = "Andrew Kane" spec.email = "andrew@ankane.org" spec.files = Dir["*.{md,txt}", "{app,config,lib}/**/*"] spec.require_path = "lib" spec.required_ruby_version = ">= 2.4" spec.add_dependency "actionmailer", ">= 5" spec.add_dependency "addressable", ">= 2.3.2" spec.add_dependency "nokogiri" spec.add_dependency "safely_block", ">= 0.1.1" spec.add_development_dependency "bundler" spec.add_development_dependency "rake" spec.add_development_dependency "minitest" spec.add_development_dependency "activerecord" spec.add_development_dependency "combustion" spec.add_development_dependency "sqlite3" end ahoy_email-1.1.1/app/000077500000000000000000000000001402100114100143615ustar00rootroot00000000000000ahoy_email-1.1.1/app/controllers/000077500000000000000000000000001402100114100167275ustar00rootroot00000000000000ahoy_email-1.1.1/app/controllers/ahoy/000077500000000000000000000000001402100114100176675ustar00rootroot00000000000000ahoy_email-1.1.1/app/controllers/ahoy/messages_controller.rb000066400000000000000000000041531402100114100242710ustar00rootroot00000000000000module Ahoy class MessagesController < ApplicationController filters = _process_action_callbacks.map(&:filter) - AhoyEmail.preserve_callbacks skip_before_action(*filters, raise: false) skip_after_action(*filters, raise: false) skip_around_action(*filters, raise: false) before_action :set_message def open # TODO move to MessageSubscriber in 2.0 if @message && !@message.opened_at @message.opened_at = Time.now @message.save! end publish :open send_data Base64.decode64("R0lGODlhAQABAPAAAAAAAAAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="), type: "image/gif", disposition: "inline" end def click # TODO move to MessageSubscriber in 2.0 if @message && !@message.clicked_at @message.clicked_at = Time.now @message.opened_at ||= @message.clicked_at if @message.respond_to?(:opened_at=) @message.save! end user_signature = params[:signature].to_s url = params[:url].to_s # TODO sign more than just url and transition to HMAC-SHA256 digest = "SHA1" signature = OpenSSL::HMAC.hexdigest(digest, AhoyEmail.secret_token, url) if ActiveSupport::SecurityUtils.secure_compare(user_signature, signature) publish :click, url: params[:url] redirect_to url else # TODO show link expired page with link to invalid redirect url in 2.0 redirect_to AhoyEmail.invalid_redirect_url || main_app.root_url end end protected def set_message @token = params[:id] model = AhoyEmail.message_model return if model.respond_to?(:column_names) && !model.column_names.include?("token") @message = model.where(token: @token).first end def publish(name, event = {}) AhoyEmail.subscribers.each do |subscriber| subscriber = subscriber.new if subscriber.is_a?(Class) && !subscriber.respond_to?(name) if subscriber.respond_to?(name) event[:message] = @message event[:controller] = self event[:token] = @token subscriber.send name, event end end end end end ahoy_email-1.1.1/app/models/000077500000000000000000000000001402100114100156445ustar00rootroot00000000000000ahoy_email-1.1.1/app/models/ahoy/000077500000000000000000000000001402100114100166045ustar00rootroot00000000000000ahoy_email-1.1.1/app/models/ahoy/message.rb000066400000000000000000000002321402100114100205520ustar00rootroot00000000000000module Ahoy class Message < ActiveRecord::Base self.table_name = "ahoy_messages" belongs_to :user, polymorphic: true, optional: true end end ahoy_email-1.1.1/config/000077500000000000000000000000001402100114100150465ustar00rootroot00000000000000ahoy_email-1.1.1/config/routes.rb000066400000000000000000000004111402100114100167100ustar00rootroot00000000000000Rails.application.routes.draw do mount AhoyEmail::Engine => "/ahoy" if AhoyEmail.api end AhoyEmail::Engine.routes.draw do scope module: "ahoy" do resources :messages, only: [] do get :open, on: :member get :click, on: :member end end end ahoy_email-1.1.1/gemfiles/000077500000000000000000000000001402100114100153745ustar00rootroot00000000000000ahoy_email-1.1.1/gemfiles/actionmailer50.gemfile000066400000000000000000000002131402100114100215360ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "actionmailer", "~> 5.0.0" gem "activerecord", "~> 5.0.0" gem "sqlite3", "~> 1.3.0" ahoy_email-1.1.1/gemfiles/actionmailer51.gemfile000066400000000000000000000001611402100114100215410ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "actionmailer", "~> 5.1.0" gem "activerecord", "~> 5.1.0" ahoy_email-1.1.1/gemfiles/actionmailer52.gemfile000066400000000000000000000001611402100114100215420ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "actionmailer", "~> 5.2.0" gem "activerecord", "~> 5.2.0" ahoy_email-1.1.1/gemfiles/actionmailer60.gemfile000066400000000000000000000001611402100114100215410ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "actionmailer", "~> 6.0.0" gem "activerecord", "~> 6.0.0" ahoy_email-1.1.1/gemfiles/mongoid6.gemfile000066400000000000000000000001151402100114100204450ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "mongoid", "~> 6.0.0" ahoy_email-1.1.1/gemfiles/mongoid7.gemfile000066400000000000000000000001151402100114100204460ustar00rootroot00000000000000source "https://rubygems.org" gemspec path: ".." gem "mongoid", "~> 7.0.0" ahoy_email-1.1.1/lib/000077500000000000000000000000001402100114100143475ustar00rootroot00000000000000ahoy_email-1.1.1/lib/ahoy_email.rb000066400000000000000000000045421402100114100170100ustar00rootroot00000000000000# dependencies require "active_support" require "addressable/uri" require "nokogiri" require "openssl" require "safely/core" # modules require "ahoy_email/processor" require "ahoy_email/tracker" require "ahoy_email/observer" require "ahoy_email/mailer" require "ahoy_email/version" require "ahoy_email/engine" if defined?(Rails) module AhoyEmail mattr_accessor :secret_token, :default_options, :subscribers, :invalid_redirect_url, :track_method, :api, :preserve_callbacks mattr_writer :message_model self.api = false self.default_options = { message: true, open: false, click: false, utm_params: false, utm_source: -> { mailer_name }, utm_medium: "email", utm_term: nil, utm_content: nil, utm_campaign: -> { action_name }, user: -> { (defined?(@user) && @user) || (respond_to?(:params) && params && params[:user]) || (message.to.try(:size) == 1 ? (User.find_by(email: message.to.first) rescue nil) : nil) }, mailer: -> { "#{self.class.name}##{action_name}" }, url_options: {}, extra: {}, unsubscribe_links: false } self.track_method = lambda do |data| message = data[:message] ahoy_message = AhoyEmail.message_model.new ahoy_message.to = Array(message.to).join(", ") if ahoy_message.respond_to?(:to=) ahoy_message.user = data[:user] if ahoy_message.respond_to?(:user=) ahoy_message.mailer = data[:mailer] if ahoy_message.respond_to?(:mailer=) ahoy_message.subject = message.subject if ahoy_message.respond_to?(:subject=) ahoy_message.content = message.encoded if ahoy_message.respond_to?(:content=) AhoyEmail::Processor::UTM_PARAMETERS.each do |k| ahoy_message.send("#{k}=", data[k.to_sym]) if ahoy_message.respond_to?("#{k}=") end ahoy_message.token = data[:token] if ahoy_message.respond_to?(:token=) ahoy_message.assign_attributes(data[:extra] || {}) ahoy_message.sent_at = Time.now ahoy_message.save! ahoy_message end self.subscribers = [] self.preserve_callbacks = [] self.message_model = -> { ::Ahoy::Message } def self.message_model model = defined?(@@message_model) && @@message_model model = model.call if model.respond_to?(:call) model end end ActiveSupport.on_load(:action_mailer) do include AhoyEmail::Mailer register_observer AhoyEmail::Observer Mail::Message.send(:attr_accessor, :ahoy_data, :ahoy_message) end ahoy_email-1.1.1/lib/ahoy_email/000077500000000000000000000000001402100114100164565ustar00rootroot00000000000000ahoy_email-1.1.1/lib/ahoy_email/engine.rb000066400000000000000000000011721402100114100202510ustar00rootroot00000000000000require "rails/engine" module AhoyEmail class Engine < ::Rails::Engine initializer "ahoy_email" do |app| AhoyEmail.secret_token ||= begin creds = if app.respond_to?(:credentials) && app.credentials.secret_key_base app.credentials elsif app.respond_to?(:secrets) app.secrets else app.config end token = creds.respond_to?(:secret_key_base) ? creds.secret_key_base : creds.secret_token token ||= app.secret_key_base # should come first, but need to maintain backward compatibility token end end end end ahoy_email-1.1.1/lib/ahoy_email/mailer.rb000066400000000000000000000021541402100114100202560ustar00rootroot00000000000000module AhoyEmail module Mailer extend ActiveSupport::Concern included do attr_writer :ahoy_options after_action :save_ahoy_options end class_methods do def track(**options) before_action(options.slice(:only, :except)) do self.ahoy_options = ahoy_options.merge(message: true).merge(options.except(:only, :except)) end end end def track(**options) self.ahoy_options = ahoy_options.merge(message: true).merge(options) end def ahoy_options @ahoy_options ||= AhoyEmail.default_options end def save_ahoy_options Safely.safely do # do message first for performance message = ahoy_options[:message] message = message.respond_to?(:call) ? instance_exec(&message) : message if message options = {} ahoy_options.except(:message).each do |k, v| # execute options in mailer content options[k] = v.respond_to?(:call) ? instance_exec(&v) : v end AhoyEmail::Processor.new(self, options).perform end end end end end ahoy_email-1.1.1/lib/ahoy_email/observer.rb000066400000000000000000000002101402100114100206230ustar00rootroot00000000000000module AhoyEmail class Observer def self.delivered_email(message) AhoyEmail::Tracker.new(message).perform end end end ahoy_email-1.1.1/lib/ahoy_email/processor.rb000066400000000000000000000112661402100114100210300ustar00rootroot00000000000000module AhoyEmail class Processor attr_reader :mailer, :options UTM_PARAMETERS = %w(utm_source utm_medium utm_term utm_content utm_campaign) def initialize(mailer, options) @mailer = mailer @options = options unknown_keywords = options.keys - AhoyEmail.default_options.keys raise ArgumentError, "unknown keywords: #{unknown_keywords.join(", ")}" if unknown_keywords.any? end def perform track_open if options[:open] track_links if options[:utm_params] || options[:click] track_message end protected def message mailer.message end def token @token ||= SecureRandom.urlsafe_base64(32).gsub(/[\-_]/, "").first(32) end def track_message data = { mailer: options[:mailer], extra: options[:extra], user: options[:user] } # legacy, remove in next major version user = options[:user] if user data[:user_type] = user.model_name.name id = user.id data[:user_id] = id.is_a?(Integer) ? id : id.to_s end if options[:open] || options[:click] data[:token] = token end if options[:utm_params] UTM_PARAMETERS.map(&:to_sym).each do |k| data[k] = options[k] if options[k] end end mailer.message.ahoy_data = data end def track_open if html_part? part = message.html_part || message raw_source = part.body.raw_source regex = /<\/body>/i url = url_for( controller: "ahoy/messages", action: "open", id: token, format: "gif" ) pixel = ActionController::Base.helpers.image_tag(url, size: "1x1", alt: "") # try to add before body tag if raw_source.match(regex) part.body = raw_source.gsub(regex, "#{pixel}\\0") else part.body = raw_source + pixel end end end def track_links if html_part? part = message.html_part || message # TODO use Nokogiri::HTML::DocumentFragment.parse in 2.0 doc = Nokogiri::HTML(part.body.raw_source) doc.css("a[href]").each do |link| uri = parse_uri(link["href"]) next unless trackable?(uri) # utm params first if options[:utm_params] && !skip_attribute?(link, "utm-params") params = uri.query_values(Array) || [] UTM_PARAMETERS.each do |key| next if params.any? { |k, _v| k == key } || !options[key.to_sym] params << [key, options[key.to_sym]] end uri.query_values = params link["href"] = uri.to_s end if options[:click] && !skip_attribute?(link, "click") raise "Secret token is empty" unless AhoyEmail.secret_token # TODO sign more than just url and transition to HMAC-SHA256 signature = OpenSSL::HMAC.hexdigest("SHA1", AhoyEmail.secret_token, link["href"]) link["href"] = url_for( controller: "ahoy/messages", action: "click", id: token, url: link["href"], signature: signature ) end end # ampersands converted to & # https://github.com/sparklemotion/nokogiri/issues/1127 # not ideal, but should be equivalent in html5 # https://stackoverflow.com/questions/15776556/whats-the-difference-between-and-amp-in-html5 # escaping technically required before html5 # https://stackoverflow.com/questions/3705591/do-i-encode-ampersands-in-a-href part.body = doc.to_s end end def html_part? (message.html_part || message).content_type =~ /html/ end def skip_attribute?(link, suffix) attribute = "data-skip-#{suffix}" if link[attribute] # remove it link.remove_attribute(attribute) true elsif link["href"].to_s =~ /unsubscribe/i && !options[:unsubscribe_links] # try to avoid unsubscribe links true else false end end # Filter trackable URIs, i.e. absolute one with http def trackable?(uri) uri && uri.absolute? && %w(http https).include?(uri.scheme) end # Parse href attribute # Return uri if valid, nil otherwise def parse_uri(href) # to_s prevent to return nil from this method Addressable::URI.heuristic_parse(href.to_s) rescue nil end def url_for(opt) opt = (ActionMailer::Base.default_url_options || {}) .merge(options[:url_options]) .merge(opt) AhoyEmail::Engine.routes.url_helpers.url_for(opt) end end end ahoy_email-1.1.1/lib/ahoy_email/tracker.rb000066400000000000000000000006751402100114100204460ustar00rootroot00000000000000module AhoyEmail class Tracker attr_reader :message def initialize(message) @message = message end def perform Safely.safely do # perform_deliveries check still needed in observer if message.perform_deliveries && message.ahoy_data data = message.ahoy_data.merge(message: message) message.ahoy_message = AhoyEmail.track_method.call(data) end end end end end ahoy_email-1.1.1/lib/ahoy_email/version.rb000066400000000000000000000000511402100114100204640ustar00rootroot00000000000000module AhoyEmail VERSION = "1.1.1" end ahoy_email-1.1.1/lib/generators/000077500000000000000000000000001402100114100165205ustar00rootroot00000000000000ahoy_email-1.1.1/lib/generators/ahoy_email/000077500000000000000000000000001402100114100206275ustar00rootroot00000000000000ahoy_email-1.1.1/lib/generators/ahoy_email/install_generator.rb000066400000000000000000000010051402100114100246640ustar00rootroot00000000000000require "rails/generators/active_record" module AhoyEmail module Generators class InstallGenerator < Rails::Generators::Base include ActiveRecord::Generators::Migration source_root File.join(__dir__, "templates") def copy_migration migration_template "install.rb", "db/migrate/create_ahoy_messages.rb", migration_version: migration_version end def migration_version "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]" end end end end ahoy_email-1.1.1/lib/generators/ahoy_email/templates/000077500000000000000000000000001402100114100226255ustar00rootroot00000000000000ahoy_email-1.1.1/lib/generators/ahoy_email/templates/install.rb.tt000066400000000000000000000004361402100114100252510ustar00rootroot00000000000000class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %> def change create_table :ahoy_messages do |t| t.references :user, polymorphic: true t.text :to t.string :mailer t.text :subject t.datetime :sent_at end end end ahoy_email-1.1.1/test/000077500000000000000000000000001402100114100145605ustar00rootroot00000000000000ahoy_email-1.1.1/test/click_test.rb000066400000000000000000000054441402100114100172400ustar00rootroot00000000000000require_relative "test_helper" class ClickTest < ActionDispatch::IntegrationTest def test_default message = ClickMailer.welcome.deliver_now refute_body "click", message end def test_basic message = ClickMailer.basic.deliver_now assert_body "click", message click_link(message) assert_redirected_to "https://example.org" assert ahoy_message.clicked_at end def test_query_params message = ClickMailer.query_params.deliver_now assert_body "click", message click_link(message) assert_redirected_to "https://example.org?a=1&b=2" assert ahoy_message.clicked_at end def test_subscriber with_subscriber(EmailSubscriber.new) do message = ClickMailer.basic.deliver_now click_link(message) assert_equal 1, $click_events.size click_event = $click_events.first assert_equal "https://example.org", click_event[:url] assert_equal ahoy_message, click_event[:message] assert click_event[:token] end end def test_subscriber_class with_subscriber(EmailSubscriber) do message = ClickMailer.basic.deliver_now click_link(message) assert_equal 1, $click_events.size click_event = $click_events.first assert_equal "https://example.org", click_event[:url] assert_equal ahoy_message, click_event[:message] assert click_event[:token] end end def test_bad_signature message = ClickMailer.basic.deliver_now assert_body "click", message url = /a href=\"([^"]+)\"/.match(message.body.decoded)[1] get url.sub("signature=", "signature=bad") assert_redirected_to root_url end def test_missing_message with_subscriber(EmailSubscriber) do message = ClickMailer.basic.deliver_now token = ahoy_message.token Ahoy::Message.delete_all click_link(message) assert_equal 1, $click_events.size click_event = $click_events.first assert_equal "https://example.org", click_event[:url] assert_nil click_event[:message] assert_equal token, click_event[:token] end end def test_mailto message = ClickMailer.mailto.deliver_now assert_body '', message end def test_app message = ClickMailer.app.deliver_now assert_body '', message end def test_schemeless message = ClickMailer.schemeless.deliver_now assert_body "click", message end def test_conditional message = ClickMailer.conditional(false).deliver_now refute_body "click", message message = ClickMailer.conditional(true).deliver_now assert_body "click", message end def click_link(message) url = /href=\"([^"]+)\"/.match(message.body.decoded)[1] # unescape entities like browser does url = CGI.unescapeHTML(url) get url end end ahoy_email-1.1.1/test/extra_test.rb000066400000000000000000000005711402100114100172720ustar00rootroot00000000000000require_relative "test_helper" class ExtraTest < Minitest::Test def test_default ExtraMailer.welcome.deliver_now assert_nil ahoy_message.coupon_id end def test_basic ExtraMailer.basic.deliver_now assert_equal 1, ahoy_message.coupon_id end def test_dynamic ExtraMailer.dynamic(2).deliver_now assert_equal 2, ahoy_message.coupon_id end end ahoy_email-1.1.1/test/internal/000077500000000000000000000000001402100114100163745ustar00rootroot00000000000000ahoy_email-1.1.1/test/internal/app/000077500000000000000000000000001402100114100171545ustar00rootroot00000000000000ahoy_email-1.1.1/test/internal/app/controllers/000077500000000000000000000000001402100114100215225ustar00rootroot00000000000000ahoy_email-1.1.1/test/internal/app/controllers/application_controller.rb000066400000000000000000000000711402100114100266130ustar00rootroot00000000000000class ApplicationController < ActionController::Base end ahoy_email-1.1.1/test/internal/app/controllers/home_controller.rb000066400000000000000000000001201402100114100252330ustar00rootroot00000000000000class HomeController < ApplicationController def index head :ok end end ahoy_email-1.1.1/test/internal/app/mailers/000077500000000000000000000000001402100114100206105ustar00rootroot00000000000000ahoy_email-1.1.1/test/internal/app/mailers/application_mailer.rb000066400000000000000000000004141402100114100247700ustar00rootroot00000000000000class ApplicationMailer < ActionMailer::Base default from: "from@example.org", to: "to@example.org", subject: "Hello", body: "World" def mail_html(html) mail do |format| format.html { render plain: html } end end end ahoy_email-1.1.1/test/internal/app/mailers/click_mailer.rb000066400000000000000000000013061402100114100235530ustar00rootroot00000000000000class ClickMailer < ApplicationMailer track click: true, except: [:welcome] track click: -> { @track }, only: [:conditional] def welcome mail_html('Test') end def basic mail_html('Test') end def query_params mail_html('Test') end def mailto mail_html('Test') end def app mail_html('Test') end def schemeless mail_html('Test') end def conditional(track) @track = track mail_html('Test') end end ahoy_email-1.1.1/test/internal/app/mailers/extra_mailer.rb000066400000000000000000000004261402100114100236130ustar00rootroot00000000000000class ExtraMailer < ApplicationMailer track extra: {coupon_id: 1}, only: [:basic] track extra: -> { {coupon_id: @coupon_id} }, only: [:dynamic] def welcome mail end def basic mail end def dynamic(coupon_id) @coupon_id = coupon_id mail end end ahoy_email-1.1.1/test/internal/app/mailers/message_mailer.rb000066400000000000000000000005661402100114100241210ustar00rootroot00000000000000class MessageMailer < ApplicationMailer track message: false, only: [:other] track message: true, only: [:other2] after_action :prevent_delivery, only: [:no_deliver] def welcome mail end def other mail end def other2 mail end def no_deliver mail end private def prevent_delivery mail.perform_deliveries = false end end ahoy_email-1.1.1/test/internal/app/mailers/open_mailer.rb000066400000000000000000000002341402100114100234260ustar00rootroot00000000000000class OpenMailer < ApplicationMailer track open: true, only: [:basic] def welcome mail_html('Hi') end def basic mail_html('Hi') end end ahoy_email-1.1.1/test/internal/app/mailers/unmailed_mailer.rb000066400000000000000000000001751402100114100242670ustar00rootroot00000000000000# extend ActionMailer::Base directly # to prevent default to class UnmailedMailer < ActionMailer::Base def hello end end ahoy_email-1.1.1/test/internal/app/mailers/user_mailer.rb000066400000000000000000000004351402100114100234460ustar00rootroot00000000000000class UserMailer < ApplicationMailer track user: -> { @some_user }, only: [:dynamic] def welcome mail end def user_var(user) @user = user mail end def to_field(to) mail to: to end def dynamic(some_user) @some_user = some_user mail end end ahoy_email-1.1.1/test/internal/app/mailers/utm_params_mailer.rb000066400000000000000000000006631402100114100246430ustar00rootroot00000000000000class UtmParamsMailer < ApplicationMailer track utm_params: true, except: [:welcome] def welcome mail_html('Test') end def basic mail_html('Test') end def array_params mail_html('Hi') end def nested mail_html('') end end ahoy_email-1.1.1/test/internal/app/models/000077500000000000000000000000001402100114100204375ustar00rootroot00000000000000ahoy_email-1.1.1/test/internal/app/models/user.rb000066400000000000000000000000441402100114100217400ustar00rootroot00000000000000class User < ActiveRecord::Base end ahoy_email-1.1.1/test/internal/config/000077500000000000000000000000001402100114100176415ustar00rootroot00000000000000ahoy_email-1.1.1/test/internal/config/database.yml000066400000000000000000000001001402100114100221170ustar00rootroot00000000000000test: adapter: sqlite3 database: db/combustion_test.sqlite ahoy_email-1.1.1/test/internal/config/routes.rb000066400000000000000000000000711402100114100215050ustar00rootroot00000000000000Rails.application.routes.draw do root "home#index" end ahoy_email-1.1.1/test/internal/db/000077500000000000000000000000001402100114100167615ustar00rootroot00000000000000ahoy_email-1.1.1/test/internal/db/schema.rb000066400000000000000000000010771402100114100205530ustar00rootroot00000000000000ActiveRecord::Schema.define do create_table :ahoy_messages, force: true do |t| t.references :user, polymorphic: true t.text :to t.string :mailer t.text :subject t.datetime :sent_at # opens & clicks t.string :token t.datetime :opened_at t.datetime :clicked_at # extra t.integer :coupon_id # legacy t.text :content t.string :utm_source t.string :utm_medium t.string :utm_term t.string :utm_content t.string :utm_campaign end create_table :users, force: true do |t| t.string :email end end ahoy_email-1.1.1/test/message_test.rb000066400000000000000000000016231402100114100175720ustar00rootroot00000000000000require_relative "test_helper" class MessageTest < Minitest::Test def test_default MessageMailer.welcome.deliver_now assert ahoy_message assert_equal "Hello", ahoy_message.subject assert_match "Hello", ahoy_message.content assert_match "World", ahoy_message.content end def test_false MessageMailer.other.deliver_now assert_nil ahoy_message end def test_prevent_delivery MessageMailer.no_deliver.deliver_now assert_nil ahoy_message end def test_default_false with_default(message: false) do MessageMailer.welcome.deliver_now assert_nil ahoy_message end end def test_default_false_track with_default(message: false) do MessageMailer.other2.deliver_now assert ahoy_message end end def test_ahoy_message message = MessageMailer.welcome.deliver_now assert_equal message.ahoy_message, ahoy_message end end ahoy_email-1.1.1/test/open_test.rb000066400000000000000000000014511402100114100171060ustar00rootroot00000000000000require_relative "test_helper" class OpenTest < ActionDispatch::IntegrationTest def test_default message = OpenMailer.welcome.deliver_now refute_body "open.gif", message end def test_basic message = OpenMailer.basic.deliver_now assert_body "open.gif", message open_message(message) assert_response :success assert ahoy_message.opened_at end def test_subscriber with_subscriber(EmailSubscriber.new) do message = OpenMailer.basic.deliver_now open_message(message) assert_equal 1, $open_events.size open_event = $open_events.first assert_equal ahoy_message, open_event[:message] assert open_event[:token] end end def open_message(message) url = /src=\"([^"]+)\"/.match(message.body.decoded)[1] get url end end ahoy_email-1.1.1/test/support/000077500000000000000000000000001402100114100162745ustar00rootroot00000000000000ahoy_email-1.1.1/test/support/mongoid.rb000066400000000000000000000015351402100114100202610ustar00rootroot00000000000000Mongoid.logger.level = Logger::INFO Mongo::Logger.logger.level = Logger::INFO if defined?(Mongo::Logger) Mongoid.configure do |config| config.connect_to "ahoy_email_test" end class User include Mongoid::Document field :email, type: String end class Ahoy::Message include Mongoid::Document belongs_to :user, polymorphic: true, optional: true, index: true field :to, type: String field :mailer, type: String field :subject, type: String field :sent_at, type: Time # opens & clicks field :token, type: String field :opened_at, type: Time field :clicked_at, type: Time # extra field :coupon_id, type: Integer # legacy field :content, type: String field :utm_source, type: String field :utm_campaign, type: String field :utm_term, type: String field :utm_medium, type: String field :utm_content, type: String end ahoy_email-1.1.1/test/test_helper.rb000066400000000000000000000031011402100114100174160ustar00rootroot00000000000000require "bundler/setup" require "combustion" Bundler.require(:default) require "minitest/autorun" require "minitest/pride" AhoyEmail.api = true Combustion.path = "test/internal" Combustion.initialize! :active_record, :action_mailer do if ActiveRecord::VERSION::MAJOR < 6 && config.active_record.sqlite3.respond_to?(:represent_boolean_as_integer) config.active_record.sqlite3.represent_boolean_as_integer = true end logger = ActiveSupport::Logger.new(ENV["VERBOSE"] ? STDOUT : nil) config.logger = logger end require_relative "support/mongoid" if defined?(Mongoid) ActionMailer::Base.delivery_method = :test class EmailSubscriber def open(event) $open_events << event end def click(event) $click_events << event end end class Minitest::Test def setup Ahoy::Message.delete_all end def ahoy_message Ahoy::Message.last end def refute_body(str, message) refute_match str, message.body.decoded end def assert_body(str, message) assert_match str, message.body.decoded end def params_supported? Rails.version > "5.1.0" end def with_default(options) previous_options = AhoyEmail.default_options.dup begin AhoyEmail.default_options.merge!(options) yield ensure AhoyEmail.default_options = previous_options end end def with_subscriber(subscriber) previous_subscribers = AhoyEmail.subscribers begin $open_events = [] $click_events = [] AhoyEmail.subscribers = [subscriber] yield ensure AhoyEmail.subscribers = previous_subscribers end end end ahoy_email-1.1.1/test/unmailed_test.rb000066400000000000000000000002431402100114100177410ustar00rootroot00000000000000require_relative "test_helper" class UnmailedTest < Minitest::Test def test_unmailed UnmailedMailer.hello.deliver_now assert_nil ahoy_message end end ahoy_email-1.1.1/test/user_test.rb000066400000000000000000000014611402100114100171240ustar00rootroot00000000000000require_relative "test_helper" class UserTest < Minitest::Test def setup super User.delete_all end def test_no_user UserMailer.welcome.deliver_now assert_nil ahoy_message.user end def test_user_var user = User.create! UserMailer.user_var(user).deliver_now assert_equal user, ahoy_message.user end def test_user_param skip unless params_supported? user = User.create! UserMailer.with(user: user).welcome.deliver_now assert_equal user, ahoy_message.user end def test_to user = User.create!(email: "test@example.org") UserMailer.to_field(user.email).deliver_now assert_equal user, ahoy_message.user end def test_dynamic user = User.create! UserMailer.dynamic(user).deliver_now assert_equal user, ahoy_message.user end end ahoy_email-1.1.1/test/utm_params_test.rb000066400000000000000000000017401402100114100203160ustar00rootroot00000000000000require_relative "test_helper" class UtmParamsTest < Minitest::Test def test_default message = UtmParamsMailer.welcome.deliver_now refute_body "utm", message assert_nil ahoy_message.utm_campaign assert_nil ahoy_message.utm_medium assert_nil ahoy_message.utm_source end def test_basic message = UtmParamsMailer.basic.deliver_now assert_body "utm_campaign=basic", message assert_body "utm_medium=email", message assert_body "utm_source=utm_params_mailer", message assert_equal "basic", ahoy_message.utm_campaign assert_equal "email", ahoy_message.utm_medium assert_equal "utm_params_mailer", ahoy_message.utm_source end def test_array_params message = UtmParamsMailer.array_params.deliver_now assert_body "baz%5B%5D=1&baz%5B%5D=2", message end def test_nested message = UtmParamsMailer.nested.deliver_now assert_body "utm_medium=email", message assert_body '', message end end