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.
@@ -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
+ })();
@@ -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 :output_directory, :layout
22
- attr_accessor :output_file
24
+ attr_reader :config
23
25
 
24
- def output_directory=(val)
25
- @output_directory = Pathname.new(val)
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
- yield
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.output_directory = File.join(Dir.tmpdir, 'postmortem')
63
- Postmortem.output_file = STDOUT
64
- Postmortem.layout = File.expand_path(File.join(__dir__, '..', 'layout', 'default.html.erb'))
65
- Postmortem.try_load('action_mailer', 'active_support') { require 'postmortem/action_mailer' }
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')
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'postmortem/adapters/base'
4
4
  require 'postmortem/adapters/action_mailer'
5
+ require 'postmortem/adapters/mail'
6
+ require 'postmortem/adapters/pony'
5
7
 
6
8
  module Postmortem
7
9
  # Adapters for various email senders (e.g. ActionMailer).
@@ -6,16 +6,55 @@ module Postmortem
6
6
  class ActionMailer < Base
7
7
  private
8
8
 
9
- def adapted(data)
9
+ def adapted
10
10
  {
11
- from: data[:from],
12
- to: data[:to],
13
- cc: data[:cc],
14
- bcc: data[:bcc],
15
- subject: data[:subject],
16
- html_body: Mail.new(data[:mail]).html_part.decoded.strip
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
- @mail = adapted(data)
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
- %i[from to cc bcc subject html_body].each do |method_name|
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
- @mail[method_name]
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(_data)
20
- raise NotImplementedError,
21
- 'Adapter must be a child class of Base which implements #adapted'
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