postmortem 0.1.0 → 0.2.1
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/.gitignore +2 -1
- data/.rubocop.yml +1 -0
- data/.ruby-version +1 -0
- data/README.md +61 -10
- data/doc/screenshot.png +0 -0
- data/layout/default.html.erb +69 -41
- data/layout/dependencies.css +11 -0
- data/layout/dependencies.js +14 -0
- data/layout/headers_template.html +35 -0
- data/layout/index.html.erb +28 -0
- data/layout/layout.css +160 -0
- data/layout/layout.js +248 -0
- data/lib/postmortem.rb +28 -15
- data/lib/postmortem/adapters.rb +2 -0
- data/lib/postmortem/adapters/action_mailer.rb +46 -7
- data/lib/postmortem/adapters/base.rb +32 -6
- data/lib/postmortem/adapters/mail.rb +21 -0
- data/lib/postmortem/adapters/pony.rb +31 -0
- data/lib/postmortem/configuration.rb +35 -0
- data/lib/postmortem/delivery.rb +22 -11
- data/lib/postmortem/index.rb +60 -0
- data/lib/postmortem/layout.rb +87 -4
- data/lib/postmortem/{action_mailer.rb → plugins/action_mailer.rb} +0 -0
- data/lib/postmortem/plugins/mail.rb +11 -0
- data/lib/postmortem/plugins/pony.rb +17 -0
- data/lib/postmortem/version.rb +1 -1
- data/postmortem.gemspec +1 -0
- metadata +36 -8
- data/Gemfile.lock +0 -126
data/layout/layout.js
ADDED
@@ -0,0 +1,248 @@
|
|
1
|
+
(function () {
|
2
|
+
let previousInbox;
|
3
|
+
let currentView;
|
4
|
+
let indexUuid;
|
5
|
+
let inboxInitialized = false;
|
6
|
+
|
7
|
+
var htmlIframeDocument = document.querySelector("#html-iframe").contentDocument;
|
8
|
+
var indexIframe = document.querySelector("#index-iframe");
|
9
|
+
var textIframeDocument = document.querySelector("#text-iframe").contentDocument;
|
10
|
+
var sourceIframeDocument = document.querySelector("#source-iframe").contentDocument;
|
11
|
+
var sourceHighlightBundle = [
|
12
|
+
'<link rel="stylesheet"',
|
13
|
+
' href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.3/styles/agate.min.css"',
|
14
|
+
' integrity="sha512-mMMMPADD4HAIogAWZbv+WjZTC0itafUZNI0jQm48PMBTApXt11omF5jhS7U1kp3R2Pr6oGJ+JwQKiUkUwCQaUQ=="',
|
15
|
+
' crossorigin="anonymous" />',
|
16
|
+
'<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.18.3/highlight.min.js"',
|
17
|
+
' integrity="sha512-tHQeqtcNWlZtEh8As/4MmZ5qpy0wj04svWFK7MIzLmUVIzaHXS8eod9OmHxyBL1UET5Rchvw7Ih4ZDv5JojZww=="',
|
18
|
+
' crossorigin="anonymous"></' + 'script>',
|
19
|
+
'<script>hljs.initHighlightingOnLoad();</' + 'script>',
|
20
|
+
].join('\n');
|
21
|
+
|
22
|
+
const initialize = () => {
|
23
|
+
loadMail(initialData);
|
24
|
+
|
25
|
+
let reloadIframeTimeout = setTimeout(() => indexIframe.src += '', 3000);
|
26
|
+
|
27
|
+
window.addEventListener('message', function (ev) {
|
28
|
+
clearTimeout(reloadIframeTimeout);
|
29
|
+
reloadIframeTimeout = setTimeout(() => indexIframe.src += '', 3000);
|
30
|
+
if (indexUuid !== ev.data.uuid) renderInbox(ev.data.mails);
|
31
|
+
indexUuid = ev.data.uuid;
|
32
|
+
});
|
33
|
+
|
34
|
+
setInterval(function () { indexIframe.contentWindow.postMessage('HELO', '*'); }, 1000);
|
35
|
+
|
36
|
+
toolbar.html.onclick = function (ev) { setView('html', ev); };
|
37
|
+
toolbar.text.onclick = function (ev) { setView('text', ev); };
|
38
|
+
toolbar.source.onclick = function (ev) { setView('source', ev); };
|
39
|
+
toolbar.headers.onclick = function () { setHeadersView(!headersView); };
|
40
|
+
columnSwitch.onclick = function () { setColumnView(!twoColumnView); };
|
41
|
+
|
42
|
+
if (hasHtml) {
|
43
|
+
setView('html');
|
44
|
+
} else {
|
45
|
+
setView('text');
|
46
|
+
}
|
47
|
+
|
48
|
+
setColumnView(false);
|
49
|
+
setHeadersView(true);
|
50
|
+
|
51
|
+
$('[data-toggle="tooltip"]').tooltip();
|
52
|
+
}
|
53
|
+
|
54
|
+
|
55
|
+
var twoColumnView;
|
56
|
+
var headersView;
|
57
|
+
var headers = document.querySelector('.headers');
|
58
|
+
var inbox = document.querySelector('#inbox');
|
59
|
+
var columnSwitch = document.querySelector('.column-switch');
|
60
|
+
var headersViewSwitch = document.querySelector('.headers-view-switch');
|
61
|
+
|
62
|
+
var setHeadersView = function (enableHeadersView) {
|
63
|
+
headersView = enableHeadersView;
|
64
|
+
if (enableHeadersView) {
|
65
|
+
setOn(headersViewSwitch);
|
66
|
+
headers.classList.add('visible');
|
67
|
+
} else {
|
68
|
+
setOff(headersViewSwitch);
|
69
|
+
headers.classList.remove('visible');
|
70
|
+
}
|
71
|
+
};
|
72
|
+
|
73
|
+
var setColumnView = function (enableTwoColumnView) {
|
74
|
+
if (!inboxInitialized) return;
|
75
|
+
|
76
|
+
var container = document.querySelector('.container');
|
77
|
+
twoColumnView = enableTwoColumnView;
|
78
|
+
if (twoColumnView) {
|
79
|
+
setVisible(inbox, true);
|
80
|
+
setOn(columnSwitch);
|
81
|
+
container.classList.add('full-width');
|
82
|
+
} else {
|
83
|
+
setVisible(inbox, false);
|
84
|
+
setOff(columnSwitch);
|
85
|
+
container.classList.remove('full-width');
|
86
|
+
}
|
87
|
+
};
|
88
|
+
|
89
|
+
var contexts = ['source', 'text', 'html'];
|
90
|
+
|
91
|
+
var views = {
|
92
|
+
source: document.querySelector('.source-view'),
|
93
|
+
html: document.querySelector('.html-view'),
|
94
|
+
text: document.querySelector('.text-view'),
|
95
|
+
};
|
96
|
+
|
97
|
+
var toolbar = {
|
98
|
+
source: document.querySelector('.source-view-switch'),
|
99
|
+
html: document.querySelector('.html-view-switch'),
|
100
|
+
text: document.querySelector('.text-view-switch'),
|
101
|
+
headers: document.querySelector('.headers-view-switch'),
|
102
|
+
};
|
103
|
+
|
104
|
+
var setOn = function(element) {
|
105
|
+
element.classList.add('text-primary');
|
106
|
+
element.classList.remove('text-secondary');
|
107
|
+
};
|
108
|
+
|
109
|
+
var setOff = function(element) {
|
110
|
+
element.classList.add('text-secondary');
|
111
|
+
element.classList.remove('text-primary');
|
112
|
+
};
|
113
|
+
|
114
|
+
var setDisabled = function(element) {
|
115
|
+
element.classList.add('disabled');
|
116
|
+
element.classList.remove('text-secondary');
|
117
|
+
};
|
118
|
+
|
119
|
+
var setEnabled = function(element) {
|
120
|
+
element.classList.remove('disabled');
|
121
|
+
element.classList.add('text-secondary');
|
122
|
+
};
|
123
|
+
|
124
|
+
var setVisible = function(element, visible) {
|
125
|
+
if (visible) {
|
126
|
+
element.classList.add('visible');
|
127
|
+
} else {
|
128
|
+
element.classList.remove('visible');
|
129
|
+
}
|
130
|
+
};
|
131
|
+
|
132
|
+
var setView = function(context, ev) {
|
133
|
+
if (ev && $(ev.target).hasClass('disabled')) return;
|
134
|
+
var key;
|
135
|
+
for (i = 0; i < contexts.length; i++) {
|
136
|
+
key = contexts[i];
|
137
|
+
if (key === context) {
|
138
|
+
setOn(toolbar[key]);
|
139
|
+
setVisible(views[key], true);
|
140
|
+
} else {
|
141
|
+
setOff(toolbar[key]);
|
142
|
+
setVisible(views[key], false);
|
143
|
+
}
|
144
|
+
}
|
145
|
+
currentView = context;
|
146
|
+
};
|
147
|
+
|
148
|
+
const arrayIdentical = (a, b) => {
|
149
|
+
if (a && !b) return false;
|
150
|
+
if (a.length !== b.length) return false;
|
151
|
+
if (!a.every((item, index) => item === b[index])) return false;
|
152
|
+
|
153
|
+
return true;
|
154
|
+
};
|
155
|
+
|
156
|
+
const htmlEscape = (html) => {
|
157
|
+
return $("<div></div>").text(html).html();
|
158
|
+
};
|
159
|
+
|
160
|
+
const $headersTemplate = $("#headers-template");
|
161
|
+
|
162
|
+
const loadHeaders = (mail) => {
|
163
|
+
$template = $headersTemplate.clone();
|
164
|
+
$('#headers').html($template.html());
|
165
|
+
['subject', 'from', 'replyTo', 'to', 'cc', 'bcc'].forEach(item => {
|
166
|
+
const $item = $(`#email-${item}`)
|
167
|
+
$item.text(mail[item]);
|
168
|
+
if (!mail[item]) $item.parent().addClass('hidden');
|
169
|
+
});
|
170
|
+
};
|
171
|
+
|
172
|
+
const loadToolbar = (mail) => {
|
173
|
+
if (!mail.textBody) {
|
174
|
+
setDisabled(toolbar.text);
|
175
|
+
setView('html');
|
176
|
+
} else {
|
177
|
+
setEnabled(toolbar.text);
|
178
|
+
}
|
179
|
+
|
180
|
+
if (!mail.htmlBody) {
|
181
|
+
setDisabled(toolbar.html);
|
182
|
+
setDisabled(toolbar.source);
|
183
|
+
setView('text');
|
184
|
+
} else {
|
185
|
+
setEnabled(toolbar.html);
|
186
|
+
setEnabled(toolbar.source);
|
187
|
+
}
|
188
|
+
|
189
|
+
setDisabled(columnSwitch);
|
190
|
+
setView(currentView);
|
191
|
+
};
|
192
|
+
|
193
|
+
const loadMail = (mail) => {
|
194
|
+
htmlIframeDocument.open();
|
195
|
+
htmlIframeDocument.write(mail.htmlBody);
|
196
|
+
htmlIframeDocument.close();
|
197
|
+
|
198
|
+
textIframeDocument.open();
|
199
|
+
textIframeDocument.write(`<pre>${htmlEscape(mail.textBody)}</pre>`);
|
200
|
+
textIframeDocument.close();
|
201
|
+
|
202
|
+
sourceIframeDocument.open();
|
203
|
+
sourceIframeDocument.write(`<pre><code style="padding: 1rem;" class="language-html">${htmlEscape(mail.htmlBody)}</code></pre>`);
|
204
|
+
sourceIframeDocument.write(sourceHighlightBundle);
|
205
|
+
sourceIframeDocument.close();
|
206
|
+
|
207
|
+
loadHeaders(mail);
|
208
|
+
loadToolbar(mail);
|
209
|
+
loadDownloadLink();
|
210
|
+
};
|
211
|
+
|
212
|
+
const loadDownloadLink = () => {
|
213
|
+
const blob = new Blob([document.documentElement.innerHTML], { type: 'application/octet-stream' });
|
214
|
+
const uri = window.URL.createObjectURL(blob);
|
215
|
+
$("#download-link").attr('href', uri);
|
216
|
+
};
|
217
|
+
|
218
|
+
const renderInbox = function (mails) {
|
219
|
+
const html = mails.map((mail, invertedIndex) => {
|
220
|
+
const index = (mails.length - 1) - invertedIndex;
|
221
|
+
const parsedTimestamp = new Date(mail.timestamp);
|
222
|
+
const timestampSpan = `<span class="timestamp">${parsedTimestamp.toLocaleString()}</span>`;
|
223
|
+
const classes = ['list-group-item', 'inbox-item'];
|
224
|
+
if (window.location.hash === '#' + index) classes.push('active');
|
225
|
+
return `<li data-email-index="${index}" class="${classes.join(' ')}"><a title="${mail.subject}" href="javascript:void(0)">${mail.subject}</a>${timestampSpan}</li>`
|
226
|
+
});
|
227
|
+
if (arrayIdentical(html, previousInbox)) return;
|
228
|
+
previousInbox = html;
|
229
|
+
$('#inbox').html('<ul class="list-group">' + html.join('\n') + '</ul>');
|
230
|
+
$('.inbox-item').click((ev) => {
|
231
|
+
const $target = $(ev.currentTarget);
|
232
|
+
const index = $target.data('email-index');
|
233
|
+
$('.inbox-item').removeClass('active');
|
234
|
+
$target.addClass('active');
|
235
|
+
window.location.hash = index;
|
236
|
+
setTimeout(() => loadMail(mails[index].content), 0);
|
237
|
+
});
|
238
|
+
|
239
|
+
if (!inboxInitialized) {
|
240
|
+
setEnabled(columnSwitch);
|
241
|
+
setColumnView(true);
|
242
|
+
setVisible(inbox, true);
|
243
|
+
}
|
244
|
+
inboxInitialized = true;
|
245
|
+
};
|
246
|
+
|
247
|
+
initialize();
|
248
|
+
})();
|
data/lib/postmortem.rb
CHANGED
@@ -7,26 +7,24 @@ require 'fileutils'
|
|
7
7
|
require 'mail'
|
8
8
|
require 'erb'
|
9
9
|
require 'json'
|
10
|
+
require 'cgi'
|
10
11
|
|
11
12
|
require 'postmortem/version'
|
12
13
|
require 'postmortem/adapters'
|
13
14
|
require 'postmortem/delivery'
|
14
15
|
require 'postmortem/layout'
|
16
|
+
require 'postmortem/configuration'
|
17
|
+
require 'postmortem/index'
|
15
18
|
|
16
19
|
# HTML email inspection tool.
|
17
20
|
module Postmortem
|
18
21
|
class Error < StandardError; end
|
19
22
|
|
20
23
|
class << self
|
21
|
-
attr_reader :
|
22
|
-
attr_accessor :output_file
|
24
|
+
attr_reader :config
|
23
25
|
|
24
|
-
def
|
25
|
-
|
26
|
-
end
|
27
|
-
|
28
|
-
def layout=(val)
|
29
|
-
@layout = Pathname.new(val)
|
26
|
+
def root
|
27
|
+
Pathname.new(__dir__).parent
|
30
28
|
end
|
31
29
|
|
32
30
|
def record_delivery(mail)
|
@@ -35,15 +33,24 @@ module Postmortem
|
|
35
33
|
.tap { |delivery| log_delivery(delivery) }
|
36
34
|
end
|
37
35
|
|
38
|
-
def try_load(*args)
|
36
|
+
def try_load(*args, plugin:)
|
39
37
|
args.each { |arg| require arg }
|
40
38
|
rescue LoadError
|
41
39
|
false
|
42
40
|
else
|
43
|
-
|
41
|
+
require "postmortem/plugins/#{plugin}"
|
44
42
|
true
|
45
43
|
end
|
46
44
|
|
45
|
+
def configure
|
46
|
+
@config = Configuration.new
|
47
|
+
yield @config if block_given?
|
48
|
+
end
|
49
|
+
|
50
|
+
def clear_inbox
|
51
|
+
config.preview_directory.rmtree
|
52
|
+
end
|
53
|
+
|
47
54
|
private
|
48
55
|
|
49
56
|
def log_delivery(delivery)
|
@@ -52,14 +59,20 @@ module Postmortem
|
|
52
59
|
end
|
53
60
|
|
54
61
|
def colorized(val)
|
55
|
-
return val unless output_file.tty?
|
62
|
+
return val unless output_file.tty? || !config.colorize
|
56
63
|
|
57
64
|
"\e[34m[postmortem]\e[36m #{val}\e[0m"
|
58
65
|
end
|
66
|
+
|
67
|
+
def output_file
|
68
|
+
return STDOUT if config.log_path.nil?
|
69
|
+
|
70
|
+
@output_file ||= File.open(config.log_path, mode: 'a')
|
71
|
+
end
|
59
72
|
end
|
60
73
|
end
|
61
74
|
|
62
|
-
Postmortem.
|
63
|
-
Postmortem.
|
64
|
-
Postmortem.
|
65
|
-
Postmortem.try_load('
|
75
|
+
Postmortem.configure
|
76
|
+
Postmortem.try_load('action_mailer', 'active_support', plugin: 'action_mailer')
|
77
|
+
Postmortem.try_load('pony', plugin: 'pony')
|
78
|
+
Postmortem.try_load('mail', plugin: 'mail')
|
data/lib/postmortem/adapters.rb
CHANGED
@@ -6,16 +6,55 @@ module Postmortem
|
|
6
6
|
class ActionMailer < Base
|
7
7
|
private
|
8
8
|
|
9
|
-
def adapted
|
9
|
+
def adapted
|
10
10
|
{
|
11
|
-
from:
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
11
|
+
from: mail.from,
|
12
|
+
reply_to: mail.reply_to,
|
13
|
+
to: mail.to,
|
14
|
+
cc: mail.cc,
|
15
|
+
bcc: normalized_bcc,
|
16
|
+
subject: mail.subject,
|
17
|
+
text_body: text_part,
|
18
|
+
html_body: html_part
|
17
19
|
}
|
18
20
|
end
|
21
|
+
|
22
|
+
def text_part
|
23
|
+
return nil unless text?
|
24
|
+
return mail.body.decoded unless mail.text_part
|
25
|
+
|
26
|
+
mail.text_part.decoded
|
27
|
+
end
|
28
|
+
|
29
|
+
def html_part
|
30
|
+
return nil unless html?
|
31
|
+
return mail.body.decoded unless mail.html_part
|
32
|
+
|
33
|
+
mail.html_part.decoded
|
34
|
+
end
|
35
|
+
|
36
|
+
def mail
|
37
|
+
@mail ||= ::Mail.new(@data[:mail])
|
38
|
+
end
|
39
|
+
|
40
|
+
def normalized_bcc
|
41
|
+
::Mail.new(to: @data[:bcc]).to
|
42
|
+
end
|
43
|
+
|
44
|
+
def text?
|
45
|
+
return true unless mail.has_content_type?
|
46
|
+
return true if mail.content_type.include?('text/plain')
|
47
|
+
return true if mail.multipart? && mail.text_part
|
48
|
+
|
49
|
+
false
|
50
|
+
end
|
51
|
+
|
52
|
+
def html?
|
53
|
+
return true if mail.has_content_type? && mail.content_type.include?('text/html')
|
54
|
+
return true if mail.multipart? && mail.html_part
|
55
|
+
|
56
|
+
false
|
57
|
+
end
|
19
58
|
end
|
20
59
|
end
|
21
60
|
end
|
@@ -2,23 +2,49 @@
|
|
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)
|
8
|
-
@
|
10
|
+
@data = data
|
11
|
+
@adapted = adapted
|
12
|
+
end
|
13
|
+
|
14
|
+
def html_body=(val)
|
15
|
+
@adapted[:html_body] = val
|
9
16
|
end
|
10
17
|
|
11
|
-
|
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|
|
12
23
|
define_method method_name do
|
13
|
-
@
|
24
|
+
@adapted[method_name]
|
14
25
|
end
|
15
26
|
end
|
16
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
|
+
|
17
36
|
private
|
18
37
|
|
19
|
-
def adapted
|
20
|
-
raise NotImplementedError,
|
21
|
-
|
38
|
+
def adapted
|
39
|
+
raise NotImplementedError, 'Adapter child class must implement #adapted'
|
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
|
22
48
|
end
|
23
49
|
end
|
24
50
|
end
|