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.
- 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
|