postmortem 0.1.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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