postmortem 0.1.1 → 0.2.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/README.md +5 -8
- data/layout/default.html.erb +45 -257
- data/layout/dependencies.css +11 -0
- data/layout/dependencies.js +14 -0
- data/layout/headers_template.html +35 -0
- data/layout/layout.css +160 -0
- data/layout/layout.js +268 -0
- data/layout/postmortem_identity.html.erb +20 -0
- data/layout/postmortem_index.html.erb +25 -0
- data/lib/postmortem.rb +12 -0
- data/lib/postmortem/adapters.rb +1 -0
- data/lib/postmortem/adapters/action_mailer.rb +2 -2
- data/lib/postmortem/adapters/base.rb +27 -1
- data/lib/postmortem/adapters/mail.rb +21 -0
- data/lib/postmortem/adapters/pony.rb +1 -1
- data/lib/postmortem/configuration.rb +3 -7
- data/lib/postmortem/delivery.rb +20 -13
- data/lib/postmortem/identity.rb +20 -0
- data/lib/postmortem/index.rb +67 -0
- data/lib/postmortem/layout.rb +82 -3
- data/lib/postmortem/plugins/mail.rb +11 -0
- data/lib/postmortem/plugins/pony.rb +5 -2
- data/lib/postmortem/version.rb +1 -1
- metadata +19 -6
@@ -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')
|
data/lib/postmortem/adapters.rb
CHANGED
@@ -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
|
-
|
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
|
@@ -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, :
|
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
|
36
|
-
defined?(@
|
31
|
+
def mail_skip_delivery
|
32
|
+
defined?(@mail_skip_delivery) ? @mail_skip_delivery : true
|
37
33
|
end
|
38
34
|
end
|
39
35
|
end
|
data/lib/postmortem/delivery.rb
CHANGED
@@ -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(
|
9
|
-
@
|
10
|
-
@path = Postmortem.config.preview_directory.join(
|
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
|
-
|
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
|
21
|
-
|
25
|
+
def index
|
26
|
+
@index ||= Index.new(index_path, path, @mail)
|
27
|
+
end
|
22
28
|
|
23
|
-
|
29
|
+
def identity
|
30
|
+
@identity ||= Identity.new
|
24
31
|
end
|
25
32
|
|
26
|
-
def
|
27
|
-
|
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
|
-
|
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
|
data/lib/postmortem/layout.rb
CHANGED
@@ -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(
|
7
|
-
@
|
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 = @
|
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
|