postmortem 0.1.0 → 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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