postmortem 0.1.2 → 0.2.4
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/.rubocop.yml +4 -46
- data/.ruby-version +1 -0
- data/Gemfile +0 -2
- data/README.md +20 -16
- data/doc/screenshot.png +0 -0
- data/layout/default.html.erb +68 -254
- data/layout/dependencies.css +11 -0
- data/layout/dependencies.js +14 -0
- data/layout/favicon.b64 +1 -0
- data/layout/headers_template.html +35 -0
- data/layout/layout.css +254 -0
- data/layout/layout.js +381 -0
- data/layout/postmortem_identity.html.erb +20 -0
- data/layout/postmortem_index.html.erb +25 -0
- data/lib/postmortem.rb +14 -2
- data/lib/postmortem/adapters.rb +1 -1
- data/lib/postmortem/adapters/base.rb +31 -1
- data/lib/postmortem/adapters/mail.rb +3 -3
- data/lib/postmortem/adapters/pony.rb +3 -6
- data/lib/postmortem/configuration.rb +1 -5
- data/lib/postmortem/delivery.rb +20 -13
- data/lib/postmortem/identity.rb +20 -0
- data/lib/postmortem/index.rb +68 -0
- data/lib/postmortem/layout.rb +86 -3
- data/lib/postmortem/plugins/action_mailer.rb +7 -0
- data/lib/postmortem/version.rb +1 -1
- data/postmortem.gemspec +4 -2
- metadata +46 -6
@@ -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)
|
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
|
71
|
+
return $stdout if config.log_path.nil?
|
60
72
|
|
61
73
|
@output_file ||= File.open(config.log_path, mode: 'a')
|
62
74
|
end
|
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 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
|
-
|
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,
|
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:
|
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, :
|
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
|
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,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
|
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,91 @@ 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
|
+
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
|