postmortem 0.1.2 → 0.2.4

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,20 @@
1
+ <!DOCTYPE html>
2
+ <html>
3
+ <body>
4
+ <div data-uuid="<%= uuid %>" id="identity">
5
+ </div>
6
+ <script>
7
+ window.addEventListener('message', function (ev) {
8
+ const identity = document.querySelector('#identity');
9
+ const uuid = identity.dataset.uuid;
10
+ const type = 'identity';
11
+ ev.source.postMessage({ uuid, type }, '*');
12
+ });
13
+
14
+ setInterval(function () {
15
+ window.location.reload();
16
+ }, 3000);
17
+
18
+ </script>
19
+ </body>
20
+ </html>
@@ -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,16 @@ require 'mail'
8
8
  require 'erb'
9
9
  require 'json'
10
10
  require 'cgi'
11
+ require 'digest'
12
+ require 'securerandom'
11
13
 
12
14
  require 'postmortem/version'
13
15
  require 'postmortem/adapters'
14
16
  require 'postmortem/delivery'
15
17
  require 'postmortem/layout'
16
18
  require 'postmortem/configuration'
19
+ require 'postmortem/identity'
20
+ require 'postmortem/index'
17
21
 
18
22
  # HTML email inspection tool.
19
23
  module Postmortem
@@ -22,6 +26,10 @@ module Postmortem
22
26
  class << self
23
27
  attr_reader :config
24
28
 
29
+ def root
30
+ Pathname.new(__dir__).parent
31
+ end
32
+
25
33
  def record_delivery(mail)
26
34
  Delivery.new(mail)
27
35
  .tap(&:record)
@@ -42,10 +50,14 @@ module Postmortem
42
50
  yield @config if block_given?
43
51
  end
44
52
 
53
+ def clear_inbox
54
+ config.preview_directory.rmtree
55
+ end
56
+
45
57
  private
46
58
 
47
59
  def log_delivery(delivery)
48
- output_file.write(colorized(delivery.path.to_s) + "\n")
60
+ output_file.write("#{colorized(delivery.path.to_s)}\n")
49
61
  output_file.flush
50
62
  end
51
63
 
@@ -56,7 +68,7 @@ module Postmortem
56
68
  end
57
69
 
58
70
  def output_file
59
- return STDOUT if config.log_path.nil?
71
+ return $stdout if config.log_path.nil?
60
72
 
61
73
  @output_file ||= File.open(config.log_path, mode: 'a')
62
74
  end
@@ -6,7 +6,7 @@ require 'postmortem/adapters/mail'
6
6
  require 'postmortem/adapters/pony'
7
7
 
8
8
  module Postmortem
9
- # Adapters for various email senders (e.g. ActionMailer).
9
+ # Adapters for various email senders (e.g. Mail, Pony).
10
10
  module Adapters
11
11
  end
12
12
  end
@@ -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 message_id].freeze
6
+
5
7
  # Base interface implementation for all Postmortem adapters.
6
8
  class Base
7
9
  def initialize(data)
@@ -9,17 +11,45 @@ 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
+ (%i[id] + FIELDS).map { |field| [camelize(field.to_s), public_send(field)] }.to_h
20
+ end
21
+
22
+ def id
23
+ @id ||= SecureRandom.uuid
24
+ end
25
+
26
+ FIELDS.each do |method_name|
13
27
  define_method method_name do
14
28
  @adapted[method_name]
15
29
  end
16
30
  end
17
31
 
32
+ def html_body
33
+ @adapted[:html_body].to_s
34
+ end
35
+
36
+ def text_body
37
+ @adapted[:text_body].to_s
38
+ end
39
+
18
40
  private
19
41
 
20
42
  def adapted
21
43
  raise NotImplementedError, 'Adapter child class must implement #adapted'
22
44
  end
45
+
46
+ def camelize(string)
47
+ string
48
+ .split('_')
49
+ .each_with_index
50
+ .map { |substring, index| index.zero? ? substring : substring.capitalize }
51
+ .join
52
+ end
23
53
  end
24
54
  end
25
55
  end
@@ -7,10 +7,10 @@ module Postmortem
7
7
  private
8
8
 
9
9
  def adapted
10
- %i[from reply_to to cc bcc subject]
11
- .map { |field| [field, @data.public_send(field)] }
10
+ %i[from reply_to to cc bcc subject message_id]
11
+ .map { |field| [field, mail.public_send(field)] }
12
12
  .to_h
13
- .merge({ text_body: @data.text_part, html_body: @data.html_part })
13
+ .merge({ text_body: mail.text_part&.decoded, html_body: mail.html_part&.decoded })
14
14
  end
15
15
 
16
16
  def mail
@@ -8,14 +8,11 @@ module Postmortem
8
8
 
9
9
  def adapted
10
10
  {
11
- from: mail.from,
12
- reply_to: mail.reply_to,
13
- to: mail.to,
14
- cc: mail.cc,
15
- bcc: mail.bcc,
11
+ from: mail.from, reply_to: mail.reply_to, to: mail.to, cc: mail.cc, bcc: mail.bcc,
16
12
  subject: mail.subject,
17
13
  text_body: @data[:body],
18
- html_body: @data[:html_body]
14
+ html_body: @data[:html_body],
15
+ message_id: mail.message_id # We use a synthetic Mail instance so this is a bit useless.
19
16
  }
20
17
  end
21
18
 
@@ -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, :mail_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
@@ -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,68 @@
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.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
+ id: @mail.id,
47
+ content: @mail.serializable
48
+ }
49
+ end
50
+
51
+ def lines
52
+ @lines ||= @index_path.read.split("\n")
53
+ end
54
+
55
+ def index(position)
56
+ offset = { start: 1, end: -1 }.fetch(position)
57
+ lines.index(marker(position)) + offset
58
+ end
59
+
60
+ def marker(position)
61
+ "### INDEX #{position.to_s.upcase}"
62
+ end
63
+
64
+ def template_path
65
+ File.expand_path(File.join(__dir__, '..', '..', 'layout', 'postmortem_index.html.erb'))
66
+ end
67
+ end
68
+ 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,91 @@ 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
+ def favicon_b64
41
+ default_layout_directory.join('favicon.b64').read
42
+ end
43
+
44
+ private
45
+
46
+ def default_layout_directory
47
+ Postmortem.root.join('layout')
48
+ end
49
+
50
+ def with_inlined_images(body)
51
+ parsed = Nokogiri::HTML.parse(body)
52
+ parsed.css('img').each do |img|
53
+ uri = try_uri(img['src'])
54
+ next unless local_file?(uri)
55
+
56
+ path = located_image(uri)
57
+ img['src'] = encoded_image(path) unless path.nil?
58
+ end
59
+ parsed.to_s
60
+ end
61
+
62
+ def local_file?(uri)
63
+ return false if uri.nil?
64
+ return true if uri.host.nil?
65
+ return true if /^www\.example\.[a-z]+$/.match(uri.host)
66
+ return true if %w[127.0.0.1 localhost].include?(uri.host)
67
+
68
+ false
69
+ end
70
+
71
+ def try_uri(uri)
72
+ URI(uri)
73
+ rescue URI::InvalidURIError
74
+ nil
75
+ end
76
+
77
+ def located_image(uri)
78
+ path = uri.path.partition('/').last
79
+ common_locations.each do |location|
80
+ full_path = location.join(path)
81
+ next unless full_path.file?
82
+
83
+ return full_path
84
+ end
85
+
86
+ nil
87
+ end
88
+
89
+ def encoded_image(path)
90
+ "data:#{mime_type(path)};base64,#{Base64.encode64(path.read)}"
91
+ end
92
+
93
+ def common_locations
94
+ ['public/assets', 'app/assets/images'].map { |path| Pathname.new(path) }
95
+ end
96
+
97
+ def mime_type(path)
98
+ extension = path.extname.partition('.').last
99
+ extension == 'jpg' ? 'jpeg' : extension
100
+ end
18
101
  end
19
102
  end