Skip to content

Commit d6dec7f

Browse files
committed
Add mailer previews feature based on mail_view gem
1 parent 1602a70 commit d6dec7f

File tree

17 files changed

+736
-6
lines changed

17 files changed

+736
-6
lines changed

actionmailer/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* Add mailer previews feature based on 37 Signals mail_view gem
2+
3+
*Andrew White*
4+
15
* Calling `mail()` without arguments serves as getter for the current mail
26
message and keeps previously set headers.
37

actionmailer/lib/action_mailer.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ module ActionMailer
4141
autoload :Base
4242
autoload :DeliveryMethods
4343
autoload :MailHelper
44+
autoload :Preview
45+
autoload :Previews, 'action_mailer/preview'
4446
autoload :TestCase
4547
autoload :TestHelper
4648
end

actionmailer/lib/action_mailer/base.rb

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,25 @@ module ActionMailer
308308
# Note that unless you have a specific reason to do so, you should prefer using before_action
309309
# rather than after_action in your ActionMailer classes so that headers are parsed properly.
310310
#
311+
# = Previewing emails
312+
#
313+
# You can preview your email templates visually by adding a mailer preview file to the
314+
# <tt>ActionMailer::Base.preview_path</tt>. Since most emails do something interesting
315+
# with database data, you'll need to write some scenarios to load messages with fake data:
316+
#
317+
# class NotifierPreview < ActionMailer::Preview
318+
# def welcome
319+
# Notifier.welcome(User.first)
320+
# end
321+
# end
322+
#
323+
# Methods must return a Mail::Message object which can be generated by calling the mailer
324+
# method without the additional <tt>deliver</tt>. The location of the mailer previews
325+
# directory can be configured using the <tt>preview_path</tt> option which has a default
326+
# of <tt>test/mailers/previews</tt>:
327+
#
328+
# config.action_mailer.preview_path = "#{Rails.root}/lib/mailer_previews"
329+
#
311330
# = Configuration options
312331
#
313332
# These options are specified on the class level, like
@@ -362,6 +381,7 @@ module ActionMailer
362381
# <tt>delivery_method :test</tt>. Most useful for unit and functional testing.
363382
class Base < AbstractController::Base
364383
include DeliveryMethods
384+
include Previews
365385

366386
abstract!
367387

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
require 'active_support/descendants_tracker'
2+
3+
module ActionMailer
4+
module Previews #:nodoc:
5+
extend ActiveSupport::Concern
6+
7+
included do
8+
# Set the location of mailer previews through app configuration:
9+
#
10+
# config.action_mailer.preview_path = "#{Rails.root}/lib/mailer_previews"
11+
#
12+
class_attribute :preview_path, instance_writer: false
13+
end
14+
end
15+
16+
class Preview
17+
extend ActiveSupport::DescendantsTracker
18+
19+
class << self
20+
# Returns all mailer preview classes
21+
def all
22+
load_previews if descendants.empty?
23+
descendants
24+
end
25+
26+
# Returns the mail object for the given email name
27+
def call(email)
28+
preview = self.new
29+
preview.public_send(email)
30+
end
31+
32+
# Returns all of the available email previews
33+
def emails
34+
public_instance_methods(false).map(&:to_s).sort
35+
end
36+
37+
# Returns true if the email exists
38+
def email_exists?(email)
39+
emails.include?(email)
40+
end
41+
42+
# Returns true if the preview exists
43+
def exists?(preview)
44+
all.any?{ |p| p.preview_name == preview }
45+
end
46+
47+
# Find a mailer preview by its underscored class name
48+
def find(preview)
49+
all.find{ |p| p.preview_name == preview }
50+
end
51+
52+
# Returns the underscored name of the mailer preview without the suffix
53+
def preview_name
54+
name.sub(/Preview$/, '').underscore
55+
end
56+
57+
protected
58+
def load_previews #:nodoc:
59+
Dir["#{preview_path}/**/*_preview.rb"].each{ |file| require_dependency file }
60+
end
61+
62+
def preview_path #:nodoc:
63+
Base.preview_path
64+
end
65+
end
66+
end
67+
end

actionmailer/lib/action_mailer/railtie.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,13 @@ class Railtie < Rails::Railtie # :nodoc:
4040
config.compile_methods! if config.respond_to?(:compile_methods!)
4141
end
4242
end
43+
44+
initializer "action_mailer.configure_mailer_previews", before: :set_autoload_paths do |app|
45+
if Rails.env.development?
46+
options = app.config.action_mailer
47+
options.preview_path ||= defined?(Rails.root) ? "#{Rails.root}/test/mailers/previews" : nil
48+
app.config.autoload_paths << options.preview_path
49+
end
50+
end
4351
end
4452
end

actionpack/lib/action_dispatch/routing/inspector.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def action
6969
end
7070

7171
def internal?
72-
controller.to_s =~ %r{\Arails/(info|welcome)} || path =~ %r{\A#{Rails.application.config.assets.prefix}}
72+
controller.to_s =~ %r{\Arails/(info|mailers|welcome)} || path =~ %r{\A#{Rails.application.config.assets.prefix}}
7373
end
7474

7575
def engine?

railties/lib/rails.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ module Rails
2525

2626
autoload :Info
2727
autoload :InfoController
28+
autoload :MailersController
2829
autoload :WelcomeController
2930

3031
class << self

railties/lib/rails/application/finisher.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ module Finisher
2222
initializer :add_builtin_route do |app|
2323
if Rails.env.development?
2424
app.routes.append do
25+
get '/rails/mailers' => "rails/mailers#index"
26+
get '/rails/mailers/*path' => "rails/mailers#preview"
2527
get '/rails/info/properties' => "rails/info#properties"
2628
get '/rails/info/routes' => "rails/info#routes"
2729
get '/rails/info' => "rails/info#index"

railties/lib/rails/generators/test_unit/mailer/mailer_generator.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@ module TestUnit # :nodoc:
44
module Generators # :nodoc:
55
class MailerGenerator < Base # :nodoc:
66
argument :actions, type: :array, default: [], banner: "method method"
7-
check_class_collision suffix: "Test"
7+
8+
def check_class_collision
9+
class_collisions "#{class_name}Test", "#{class_name}Preview"
10+
end
811

912
def create_test_files
1013
template "functional_test.rb", File.join('test/mailers', class_path, "#{file_name}_test.rb")
1114
end
15+
16+
def create_preview_files
17+
template "preview.rb", File.join('test/mailers/previews', class_path, "#{file_name}_preview.rb")
18+
end
1219
end
1320
end
1421
end
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<% module_namespacing do -%>
2+
class <%= class_name %>Preview < ActionMailer::Preview
3+
<% actions.each do |action| -%>
4+
5+
def <%= action %>
6+
<%= class_name %>.<%= action %>
7+
end
8+
<% end -%>
9+
10+
end
11+
<% end -%>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
require 'rails/application_controller'
2+
3+
class Rails::MailersController < Rails::ApplicationController # :nodoc:
4+
prepend_view_path ActionDispatch::DebugExceptions::RESCUES_TEMPLATE_PATH
5+
6+
before_filter :require_local!
7+
before_filter :find_preview, only: :preview
8+
9+
def index
10+
@previews = ActionMailer::Preview.all
11+
@page_title = "Mailer Previews"
12+
end
13+
14+
def preview
15+
if params[:path] == @preview.preview_name
16+
@page_title = "Mailer Previews for #{@preview.preview_name}"
17+
render action: 'mailer'
18+
else
19+
email = File.basename(params[:path])
20+
21+
if @preview.email_exists?(email)
22+
@email = @preview.call(email)
23+
24+
if params[:part]
25+
part_type = Mime::Type.lookup(params[:part])
26+
27+
if part = find_part(part_type)
28+
response.content_type = part_type
29+
render text: part.respond_to?(:decoded) ? part.decoded : part
30+
else
31+
raise AbstractController::ActionNotFound, "Email part '#{part_type}' not found in #{@preview.name}##{email}"
32+
end
33+
else
34+
@part = find_preferred_part(request.format, Mime::HTML, Mime::TEXT)
35+
render action: 'email', layout: false, formats: %w[html]
36+
end
37+
else
38+
raise AbstractController::ActionNotFound, "Email '#{email}' not found in #{@preview.name}"
39+
end
40+
end
41+
end
42+
43+
protected
44+
def find_preview
45+
candidates = []
46+
params[:path].to_s.scan(%r{/|$}){ candidates << $` }
47+
preview = candidates.detect{ |candidate| ActionMailer::Preview.exists?(candidate) }
48+
49+
if preview
50+
@preview = ActionMailer::Preview.find(preview)
51+
else
52+
raise AbstractController::ActionNotFound, "Mailer preview '#{params[:path]}' not found"
53+
end
54+
end
55+
56+
def find_preferred_part(*formats)
57+
if @email.multipart?
58+
formats.each do |format|
59+
return find_part(format) if @email.parts.any?{ |p| p.mime_type == format }
60+
end
61+
else
62+
@email
63+
end
64+
end
65+
66+
def find_part(format)
67+
if @email.multipart?
68+
@email.parts.find{ |p| p.mime_type == format }
69+
elsif @email.mime_type == format
70+
@email
71+
end
72+
end
73+
end

railties/lib/rails/templates/layouts/application.html.erb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,12 @@
2929
</style>
3030
</head>
3131
<body>
32-
<h2>Your App: <%= link_to 'properties', '/rails/info/properties' %> | <%= link_to 'routes', '/rails/info/routes' %></h2>
32+
<h2>
33+
Your App:
34+
<%= link_to 'mailers', '/rails/mailers' %> |
35+
<%= link_to 'properties', '/rails/info/properties' %> |
36+
<%= link_to 'routes', '/rails/info/routes' %>
37+
</h2>
3338
<%= yield %>
3439

3540
</body>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
<!DOCTYPE html>
2+
<html><head>
3+
<meta name="viewport" content="width=device-width" />
4+
<style type="text/css">
5+
header {
6+
width: 100%;
7+
padding: 10px 0 0 0;
8+
margin: 0;
9+
background: white;
10+
font: 12px "Lucida Grande", sans-serif;
11+
border-bottom: 1px solid #dedede;
12+
overflow: hidden;
13+
}
14+
15+
dl {
16+
margin: 0 0 10px 0;
17+
padding: 0;
18+
}
19+
20+
dt {
21+
width: 80px;
22+
padding: 1px;
23+
float: left;
24+
clear: left;
25+
text-align: right;
26+
color: #7f7f7f;
27+
}
28+
29+
dd {
30+
margin-left: 90px; /* 80px + 10px */
31+
padding: 1px;
32+
}
33+
34+
iframe {
35+
border: 0;
36+
width: 100%;
37+
height: 800px;
38+
}
39+
</style>
40+
</head>
41+
42+
<body>
43+
<header>
44+
<dl>
45+
<% if @email.respond_to?(:smtp_envelope_from) && Array(@email.from) != Array(@email.smtp_envelope_from) %>
46+
<dt>SMTP-From:</dt>
47+
<dd><%= @email.smtp_envelope_from %></dd>
48+
<% end %>
49+
50+
<% if @email.respond_to?(:smtp_envelope_to) && @email.to != @email.smtp_envelope_to %>
51+
<dt>SMTP-To:</dt>
52+
<dd><%= @email.smtp_envelope_to %></dd>
53+
<% end %>
54+
55+
<dt>From:</dt>
56+
<dd><%= @email.header['from'] %></dd>
57+
58+
<% if @email.reply_to %>
59+
<dt>Reply-To:</dt>
60+
<dd><%= @email.header['reply-to'] %></dd>
61+
<% end %>
62+
63+
<dt>To:</dt>
64+
<dd><%= @email.header['to'] %></dd>
65+
66+
<% if @email.cc %>
67+
<dt>CC:</dt>
68+
<dd><%= @email.header['cc'] %></dd>
69+
<% end %>
70+
71+
<dt>Date:</dt>
72+
<dd><%= Time.current.rfc2822 %></dd>
73+
74+
<dt>Subject:</dt>
75+
<dd><strong><%= @email.subject %></strong></dd>
76+
77+
<% unless @email.attachments.nil? || @email.attachments.empty? %>
78+
<dt>Attachments:</dt>
79+
<dd>
80+
<%= @email.attachments.map { |a| a.respond_to?(:original_filename) ? a.original_filename : a.filename }.inspect %>
81+
</dd>
82+
<% end %>
83+
84+
<% if @email.multipart? %>
85+
<dd>
86+
<select onchange="document.getElementsByName('messageBody')[0].src=this.options[this.selectedIndex].value;">
87+
<option <%= request.format == Mime::HTML ? 'selected' : '' %> value="?part=text%2Fhtml">View as HTML email</option>
88+
<option <%= request.format == Mime::TEXT ? 'selected' : '' %> value="?part=text%2Fplain">View as plain-text email</option>
89+
</select>
90+
</dd>
91+
<% end %>
92+
</dl>
93+
</header>
94+
95+
<iframe seamless name="messageBody" src="?part=<%= Rack::Utils.escape(@part.mime_type) %>"></iframe>
96+
97+
</body>
98+
</html>
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<% @previews.each do |preview| %>
2+
<h3><%= link_to preview.preview_name.titleize, "/rails/mailers/#{preview.preview_name}" %></h3>
3+
<ul>
4+
<% preview.emails.each do |email| %>
5+
<li><%= link_to email, "/rails/mailers/#{preview.preview_name}/#{email}" %></li>
6+
<% end %>
7+
</ul>
8+
<% end %>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<h3><%= @preview.preview_name.titleize %></h3>
2+
<ul>
3+
<% @preview.emails.each do |email| %>
4+
<li><%= link_to email, "/rails/mailers/#{@preview.preview_name}/#{email}" %></li>
5+
<% end %>
6+
</ul>

0 commit comments

Comments
 (0)