postmortem 0.1.1 → 0.2.3

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,25 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <div data-uuid="<%= uuid %>" id="index">
5
+ ### INDEX START
6
+ <% encoded_index.each do |encoded| -%>
7
+ <%= encoded %>
8
+ <% end -%>
9
+ ### INDEX END
10
+ </div>
11
+ <script>
12
+ window.addEventListener('message', function (ev) {
13
+ const filter = (line) => line !== '' && !line.startsWith('### INDEX');
14
+ const index = document.querySelector('#index');
15
+ const uuid = index.dataset.uuid;
16
+ const type = 'index';
17
+ const mails = index.innerText
18
+ .split('\n')
19
+ .filter(filter)
20
+ .map(line => JSON.parse(atob(line)));
21
+ ev.source.postMessage({ uuid, type, mails }, '*');
22
+ });
23
+ </script>
24
+ </body>
25
+ </html>
data/lib/postmortem.rb CHANGED
@@ -8,12 +8,15 @@ require 'mail'
8
8
  require 'erb'
9
9
  require 'json'
10
10
  require 'cgi'
11
+ require 'digest'
11
12
 
12
13
  require 'postmortem/version'
13
14
  require 'postmortem/adapters'
14
15
  require 'postmortem/delivery'
15
16
  require 'postmortem/layout'
16
17
  require 'postmortem/configuration'
18
+ require 'postmortem/identity'
19
+ require 'postmortem/index'
17
20
 
18
21
  # HTML email inspection tool.
19
22
  module Postmortem
@@ -22,6 +25,10 @@ module Postmortem
22
25
  class << self
23
26
  attr_reader :config
24
27
 
28
+ def root
29
+ Pathname.new(__dir__).parent
30
+ end
31
+
25
32
  def record_delivery(mail)
26
33
  Delivery.new(mail)
27
34
  .tap(&:record)
@@ -42,6 +49,10 @@ module Postmortem
42
49
  yield @config if block_given?
43
50
  end
44
51
 
52
+ def clear_inbox
53
+ config.preview_directory.rmtree
54
+ end
55
+
45
56
  private
46
57
 
47
58
  def log_delivery(delivery)
@@ -66,3 +77,4 @@ end
66
77
  Postmortem.configure
67
78
  Postmortem.try_load('action_mailer', 'active_support', plugin: 'action_mailer')
68
79
  Postmortem.try_load('pony', plugin: 'pony')
80
+ Postmortem.try_load('mail', plugin: 'mail')
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'postmortem/adapters/base'
4
4
  require 'postmortem/adapters/action_mailer'
5
+ require 'postmortem/adapters/mail'
5
6
  require 'postmortem/adapters/pony'
6
7
 
7
8
  module Postmortem
@@ -34,11 +34,11 @@ module Postmortem
34
34
  end
35
35
 
36
36
  def mail
37
- @mail ||= Mail.new(@data[:mail])
37
+ @mail ||= ::Mail.new(@data[:mail])
38
38
  end
39
39
 
40
40
  def normalized_bcc
41
- Mail.new(to: @data[:bcc]).to
41
+ ::Mail.new(to: @data[:bcc]).to
42
42
  end
43
43
 
44
44
  def text?
@@ -2,6 +2,8 @@
2
2
 
3
3
  module Postmortem
4
4
  module Adapters
5
+ FIELDS = %i[from reply_to to cc bcc subject text_body html_body].freeze
6
+
5
7
  # Base interface implementation for all Postmortem adapters.
6
8
  class Base
7
9
  def initialize(data)
@@ -9,17 +11,41 @@ module Postmortem
9
11
  @adapted = adapted
10
12
  end
11
13
 
12
- %i[from reply_to to cc bcc subject text_body html_body].each do |method_name|
14
+ def html_body=(val)
15
+ @adapted[:html_body] = val
16
+ end
17
+
18
+ def serializable
19
+ FIELDS.map { |field| [camelize(field.to_s), public_send(field)] }.to_h
20
+ end
21
+
22
+ FIELDS.each do |method_name|
13
23
  define_method method_name do
14
24
  @adapted[method_name]
15
25
  end
16
26
  end
17
27
 
28
+ def html_body
29
+ @adapted[:html_body].to_s
30
+ end
31
+
32
+ def text_body
33
+ @adapted[:text_body].to_s
34
+ end
35
+
18
36
  private
19
37
 
20
38
  def adapted
21
39
  raise NotImplementedError, 'Adapter child class must implement #adapted'
22
40
  end
41
+
42
+ def camelize(string)
43
+ string
44
+ .split('_')
45
+ .each_with_index
46
+ .map { |substring, index| index.zero? ? substring : substring.capitalize }
47
+ .join
48
+ end
23
49
  end
24
50
  end
25
51
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postmortem
4
+ module Adapters
5
+ # Mail adapter.
6
+ class Mail < Base
7
+ private
8
+
9
+ def adapted
10
+ %i[from reply_to to cc bcc subject]
11
+ .map { |field| [field, @data.public_send(field)] }
12
+ .to_h
13
+ .merge({ text_body: @data.text_part&.decoded, html_body: @data.html_part&.decoded })
14
+ end
15
+
16
+ def mail
17
+ @mail ||= @data
18
+ end
19
+ end
20
+ end
21
+ end
@@ -20,7 +20,7 @@ module Postmortem
20
20
  end
21
21
 
22
22
  def mail
23
- @mail ||= Mail.new(@data.select { |key| keys.include?(key) })
23
+ @mail ||= ::Mail.new(@data.select { |key| keys.include?(key) })
24
24
  end
25
25
 
26
26
  def keys
@@ -3,13 +3,9 @@
3
3
  module Postmortem
4
4
  # Provides interface for configuring Postmortem and implements sensible defaults.
5
5
  class Configuration
6
- attr_writer :colorize, :timestamp, :pony_skip_delivery
6
+ attr_writer :colorize, :mail_skip_delivery
7
7
  attr_accessor :log_path
8
8
 
9
- def timestamp
10
- defined?(@timestamp) ? @timestamp : true
11
- end
12
-
13
9
  def colorize
14
10
  defined?(@colorize) ? @colorize : true
15
11
  end
@@ -32,8 +28,8 @@ module Postmortem
32
28
  @preview_directory ||= Pathname.new(File.join(Dir.tmpdir, 'postmortem'))
33
29
  end
34
30
 
35
- def pony_skip_delivery
36
- defined?(@pony_skip_delivery) ? @pony_skip_delivery : true
31
+ def mail_skip_delivery
32
+ defined?(@mail_skip_delivery) ? @mail_skip_delivery : true
37
33
  end
38
34
  end
39
35
  end
@@ -3,34 +3,41 @@
3
3
  module Postmortem
4
4
  # Abstraction of an email delivery. Capable of writing email HTML body to disk.
5
5
  class Delivery
6
- attr_reader :path
6
+ attr_reader :path, :index_path
7
7
 
8
- def initialize(adapter)
9
- @adapter = adapter
10
- @path = Postmortem.config.preview_directory.join(filename)
8
+ def initialize(mail)
9
+ @mail = mail
10
+ @path = Postmortem.config.preview_directory.join('index.html')
11
+ @index_path = Postmortem.config.preview_directory.join('postmortem_index.html')
12
+ @identity_path = Postmortem.config.preview_directory.join('postmortem_identity.html')
11
13
  end
12
14
 
13
15
  def record
14
16
  path.parent.mkpath
15
- File.write(path, Layout.new(@adapter).content)
17
+ content = Layout.new(@mail).content
18
+ path.write(content)
19
+ index_path.write(index.content)
20
+ @identity_path.write(identity.content)
16
21
  end
17
22
 
18
23
  private
19
24
 
20
- def filename
21
- return "#{safe_subject}.html" unless Postmortem.config.timestamp
25
+ def index
26
+ @index ||= Index.new(index_path, path, @mail)
27
+ end
22
28
 
23
- "#{timestamp}__#{safe_subject}.html"
29
+ def identity
30
+ @identity ||= Identity.new
24
31
  end
25
32
 
26
- def timestamp
27
- Time.now.strftime('%Y-%m-%d_%H-%M-%S')
33
+ def subject
34
+ return 'no-subject' if @mail.subject.nil? || @mail.subject.empty?
35
+
36
+ @mail.subject
28
37
  end
29
38
 
30
39
  def safe_subject
31
- return 'no-subject' if @adapter.subject.nil? || @adapter.subject.empty?
32
-
33
- @adapter.subject.tr(' ', '_').split('').select { |char| safe_chars.include?(char) }.join
40
+ subject.tr(' ', '_').split('').select { |char| safe_chars.include?(char) }.join
34
41
  end
35
42
 
36
43
  def safe_chars
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postmortem
4
+ # Provides an HTML document that announces a unique ID to a parent page via JS message events.
5
+ class Identity
6
+ def content
7
+ ERB.new(File.read(path), nil, '-').result(binding)
8
+ end
9
+
10
+ private
11
+
12
+ def uuid
13
+ @uuid ||= SecureRandom.uuid
14
+ end
15
+
16
+ def path
17
+ File.expand_path(File.join(__dir__, '..', '..', 'layout', 'postmortem_identity.html.erb'))
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Postmortem
4
+ # Generates and parses an index of previously-sent emails.
5
+ class Index
6
+ def initialize(index_path, mail_path, mail)
7
+ @index_path = index_path
8
+ @mail_path = mail_path
9
+ @mail = mail
10
+ end
11
+
12
+ def content
13
+ mail_path = @mail_path
14
+ ERB.new(File.read(template_path), nil, '-').result(binding)
15
+ end
16
+
17
+ def size
18
+ encoded_index.size
19
+ end
20
+
21
+ private
22
+
23
+ def uuid
24
+ @uuid ||= SecureRandom.uuid
25
+ end
26
+
27
+ def timestamp
28
+ Time.now.iso8601
29
+ end
30
+
31
+ def encoded_index
32
+ return [encoded_mail] unless @index_path.file?
33
+
34
+ @encoded_index ||= [encoded_mail] + lines[index(:start)..index(:end)]
35
+ end
36
+
37
+ def encoded_mail
38
+ Base64.encode64(mail_data.merge(id: Digest::MD5.hexdigest(mail_data.to_json)).to_json).split("\n").join
39
+ end
40
+
41
+ def mail_data
42
+ {
43
+ subject: @mail.subject || '(no subject)',
44
+ timestamp: timestamp,
45
+ path: @mail_path,
46
+ content: @mail.serializable
47
+ }
48
+ end
49
+
50
+ def lines
51
+ @lines ||= @index_path.read.split("\n")
52
+ end
53
+
54
+ def index(position)
55
+ offset = { start: 1, end: -1 }.fetch(position)
56
+ lines.index(marker(position)) + offset
57
+ end
58
+
59
+ def marker(position)
60
+ "### INDEX #{position.to_s.upcase}"
61
+ end
62
+
63
+ def template_path
64
+ File.expand_path(File.join(__dir__, '..', '..', 'layout', 'postmortem_index.html.erb'))
65
+ end
66
+ end
67
+ end
@@ -3,8 +3,8 @@
3
3
  module Postmortem
4
4
  # Wraps provided body in an enclosing layout for presentation purposes.
5
5
  class Layout
6
- def initialize(adapter)
7
- @adapter = adapter
6
+ def initialize(mail)
7
+ @mail = mail
8
8
  end
9
9
 
10
10
  def format_email_array(array)
@@ -12,8 +12,87 @@ module Postmortem
12
12
  end
13
13
 
14
14
  def content
15
- mail = @adapter
15
+ mail = @mail
16
+ mail.html_body = with_inlined_images(mail.html_body) if defined?(Nokogiri)
16
17
  ERB.new(Postmortem.config.layout.read).result(binding)
17
18
  end
19
+
20
+ def styles
21
+ default_layout_directory.join('layout.css').read
22
+ end
23
+
24
+ def javascript
25
+ default_layout_directory.join('layout.js').read
26
+ end
27
+
28
+ def css_dependencies
29
+ default_layout_directory.join('dependencies.css').read
30
+ end
31
+
32
+ def javascript_dependencies
33
+ default_layout_directory.join('dependencies.js').read
34
+ end
35
+
36
+ def headers_template
37
+ default_layout_directory.join('headers_template.html').read
38
+ end
39
+
40
+ private
41
+
42
+ def default_layout_directory
43
+ Postmortem.root.join('layout')
44
+ end
45
+
46
+ def with_inlined_images(body)
47
+ parsed = Nokogiri::HTML.parse(body)
48
+ parsed.css('img').each do |img|
49
+ uri = try_uri(img['src'])
50
+ next unless local_file?(uri)
51
+
52
+ path = located_image(uri)
53
+ img['src'] = encoded_image(path) unless path.nil?
54
+ end
55
+ parsed.to_s
56
+ end
57
+
58
+ def local_file?(uri)
59
+ return false if uri.nil?
60
+ return true if uri.host.nil?
61
+ return true if /^www\.example\.[a-z]+$/.match(uri.host)
62
+ return true if %w[127.0.0.1 localhost].include?(uri.host)
63
+
64
+ false
65
+ end
66
+
67
+ def try_uri(uri)
68
+ URI(uri)
69
+ rescue URI::InvalidURIError
70
+ nil
71
+ end
72
+
73
+ def located_image(uri)
74
+ path = uri.path.partition('/').last
75
+ common_locations.each do |location|
76
+ full_path = location.join(path)
77
+ next unless full_path.file?
78
+
79
+ return full_path
80
+ end
81
+
82
+ nil
83
+ end
84
+
85
+ def encoded_image(path)
86
+ "data:#{mime_type(path)};base64,#{Base64.encode64(path.read)}"
87
+ end
88
+
89
+ def common_locations
90
+ ['public/assets', 'app/assets/images'].map { |path| Pathname.new(path) }
91
+ end
92
+
93
+ def mime_type(path)
94
+ extension = path.extname.partition('.').last
95
+ extension == 'jpg' ? 'jpeg' : extension
96
+ end
18
97
  end
19
98
  end