mailscope 0.1.0

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.
Files changed (39) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE.txt +27 -0
  4. data/README.md +210 -0
  5. data/app/assets/mailscope/INTER-LICENSE.txt +92 -0
  6. data/app/assets/mailscope/favicon.svg +6 -0
  7. data/app/assets/mailscope/inter-latin-ext.woff2 +0 -0
  8. data/app/assets/mailscope/inter-latin.woff2 +0 -0
  9. data/app/assets/mailscope/mailscope.css +594 -0
  10. data/app/assets/mailscope/mailscope.js +559 -0
  11. data/app/controllers/mailscope/application_controller.rb +30 -0
  12. data/app/controllers/mailscope/assets_controller.rb +24 -0
  13. data/app/controllers/mailscope/messages_controller.rb +139 -0
  14. data/app/helpers/mailscope/application_helper.rb +124 -0
  15. data/app/views/layouts/mailscope/application.html.erb +30 -0
  16. data/app/views/mailscope/messages/_blank_pane.html.erb +9 -0
  17. data/app/views/mailscope/messages/_list.html.erb +34 -0
  18. data/app/views/mailscope/messages/_list_item.html.erb +39 -0
  19. data/app/views/mailscope/messages/_pane.html.erb +151 -0
  20. data/app/views/mailscope/messages/_rail.html.erb +33 -0
  21. data/app/views/mailscope/messages/_shortcuts.html.erb +21 -0
  22. data/app/views/mailscope/messages/index.html.erb +66 -0
  23. data/app/views/mailscope/shared/_icons.html.erb +58 -0
  24. data/config/locales/mailscope.en.yml +105 -0
  25. data/config/locales/mailscope.pt-BR.yml +105 -0
  26. data/config/routes.rb +24 -0
  27. data/lib/mailscope/body_renderer.rb +142 -0
  28. data/lib/mailscope/configuration.rb +65 -0
  29. data/lib/mailscope/delivery_method.rb +45 -0
  30. data/lib/mailscope/engine.rb +21 -0
  31. data/lib/mailscope/mailbox.rb +39 -0
  32. data/lib/mailscope/message.rb +268 -0
  33. data/lib/mailscope/query.rb +54 -0
  34. data/lib/mailscope/storage/base.rb +35 -0
  35. data/lib/mailscope/storage/filesystem.rb +214 -0
  36. data/lib/mailscope/storage.rb +23 -0
  37. data/lib/mailscope/version.rb +5 -0
  38. data/lib/mailscope.rb +60 -0
  39. metadata +128 -0
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailscope
4
+ class MessagesController < ApplicationController
5
+ TRUTHY = %w[1 true t yes y on].freeze
6
+
7
+ before_action :load_message, only: %i[show destroy body source download attachment]
8
+
9
+ def index
10
+ load_index
11
+ @message ||= @messages.first
12
+
13
+ respond_to do |format|
14
+ format.html { render :index }
15
+ format.json { render json: index_payload }
16
+ end
17
+ end
18
+
19
+ def show
20
+ load_index
21
+
22
+ respond_to do |format|
23
+ format.html do
24
+ # The pane is fetched on its own by the front-end; a full page load
25
+ # (no JS, opened in a new tab) still renders the whole UI.
26
+ if pane_request?
27
+ render partial: 'mailscope/messages/pane', locals: { message: @message }, layout: false
28
+ else
29
+ render :index
30
+ end
31
+ end
32
+ format.json { render json: @message.to_h }
33
+ end
34
+ end
35
+
36
+ def body
37
+ renderer = BodyRenderer.new(@message, part: params[:part], block_remote: block_remote?,
38
+ theme: params[:theme])
39
+
40
+ response.headers['Content-Security-Policy'] = renderer.content_security_policy
41
+ response.headers['X-Mailscope-Blocked-Resources'] = renderer.blocked_resources.to_s
42
+ response.headers['X-Frame-Options'] = 'SAMEORIGIN'
43
+ response.headers['Cache-Control'] = 'no-store'
44
+
45
+ render html: renderer.document.html_safe, layout: false
46
+ end
47
+
48
+ def source
49
+ render plain: @message.raw, content_type: 'text/plain'
50
+ end
51
+
52
+ def download
53
+ send_data @message.raw,
54
+ filename: "#{@message.id}.eml",
55
+ type: 'message/rfc822',
56
+ disposition: 'attachment'
57
+ end
58
+
59
+ def attachment
60
+ found = @message.attachment(params[:attachment_id])
61
+ return head :not_found unless found
62
+
63
+ bytes = @message.attachment_body(found.id)
64
+ return head :not_found unless bytes
65
+
66
+ send_data bytes,
67
+ filename: found.filename,
68
+ type: found.content_type.presence || 'application/octet-stream',
69
+ disposition: inline_attachment?(found) ? 'inline' : 'attachment'
70
+ end
71
+
72
+ def destroy
73
+ storage.delete(@message.id)
74
+
75
+ respond_to do |format|
76
+ format.json { render json: { ok: true, revision: storage.revision } }
77
+ format.any { redirect_to root_path(filter_params), status: :see_other }
78
+ end
79
+ end
80
+
81
+ def clear
82
+ removed = storage.clear
83
+
84
+ respond_to do |format|
85
+ format.json { render json: { ok: true, removed: removed, revision: storage.revision } }
86
+ format.any { redirect_to root_path, status: :see_other }
87
+ end
88
+ end
89
+
90
+ # Cheap endpoint the browser polls to notice new mail.
91
+ def status
92
+ response.headers['Cache-Control'] = 'no-store'
93
+ render json: { revision: storage.revision, count: storage.all.size }
94
+ end
95
+
96
+ private
97
+
98
+ def load_index
99
+ @all = storage.all
100
+ @mailboxes = Mailbox.index(@all)
101
+ @query = Query.new(@all, mailbox: params[:mailbox], term: params[:q], only: params[:only])
102
+ @messages = @query.results
103
+ @mailbox = params[:mailbox].presence
104
+ end
105
+
106
+ def load_message
107
+ @message = storage.find(params[:id])
108
+ head :not_found unless @message
109
+ rescue InvalidLetterId
110
+ head :not_found
111
+ end
112
+
113
+ def block_remote?
114
+ return mailscope_config.block_remote_content unless params.key?(:remote)
115
+
116
+ TRUTHY.exclude?(params[:remote].to_s.downcase)
117
+ end
118
+
119
+ def inline_attachment?(found)
120
+ found.image? || found.content_type.to_s == 'application/pdf'
121
+ end
122
+
123
+ def pane_request?
124
+ request.headers['X-Mailscope-Pane'].present?
125
+ end
126
+
127
+ def filter_params
128
+ params.permit(:mailbox, :q, :only).to_h.compact_blank
129
+ end
130
+
131
+ def index_payload
132
+ {
133
+ revision: storage.revision,
134
+ mailboxes: @mailboxes.map(&:to_h),
135
+ messages: @messages.map(&:to_h)
136
+ }
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Mailscope
6
+ module ApplicationHelper
7
+ ASSET_ROOT = Mailscope::Engine.root.join('app', 'assets', 'mailscope')
8
+
9
+ # Digest of the asset bundle, so `Cache-Control: immutable` is safe and a
10
+ # gem upgrade (or a local edit while developing the gem) busts the cache.
11
+ def mailscope_assets_digest
12
+ if Rails.env.development? || ENV['MAILSCOPE_DEV']
13
+ return Digest::MD5.hexdigest(Dir[ASSET_ROOT.join('*')].map { |f| "#{f}#{File.mtime(f).to_i}" }.join)[0, 12]
14
+ end
15
+
16
+ @mailscope_assets_digest ||= Digest::MD5.hexdigest(Mailscope::VERSION)[0, 12]
17
+ end
18
+
19
+ def mailscope_asset_path(file)
20
+ asset_path(digest: mailscope_assets_digest, file: file)
21
+ end
22
+
23
+ # Shorthand for the engine's translation namespace.
24
+ def ms_t(key, **options)
25
+ t("mailscope.#{key}", **options)
26
+ end
27
+
28
+ def mailscope_time_tag(time)
29
+ tag.time(mailscope_short_time(time),
30
+ datetime: time.iso8601,
31
+ title: mailscope_datetime(time),
32
+ data: { relative: time.iso8601 })
33
+ end
34
+
35
+ def mailscope_short_time(time)
36
+ now = Time.now
37
+ seconds = (now - time).to_i
38
+
39
+ case seconds
40
+ when ...60 then ms_t('time.now')
41
+ when ...3600 then ms_t('time.minutes', count: seconds / 60)
42
+ when ...86_400 then time.strftime('%H:%M')
43
+ when ...604_800 then ms_t('time.weekdays')[time.wday]
44
+ else time.strftime('%d/%m')
45
+ end
46
+ end
47
+
48
+ # Strings the front-end needs. Serialised once into the page so the JS
49
+ # bundle stays a static, cacheable asset with no locale baked in.
50
+ def mailscope_js_strings
51
+ {
52
+ clearConfirm: ms_t('actions.clear_confirm'),
53
+ deleted: ms_t('toasts.deleted'),
54
+ deleteFailed: ms_t('toasts.delete_failed'),
55
+ clearedOne: ms_t('toasts.cleared', count: 1),
56
+ clearedMany: ms_t('toasts.cleared', count: 2).sub('2', '%{count}'),
57
+ clearFailed: ms_t('toasts.clear_failed'),
58
+ newMail: ms_t('toasts.new_mail'),
59
+ open: ms_t('toasts.open'),
60
+ listFailed: ms_t('toasts.list_failed'),
61
+ openFailed: ms_t('toasts.open_failed'),
62
+ sourceError: ms_t('message.source_error'),
63
+ emptyTitle: ms_t('empty.inbox_title'),
64
+ theme: ms_t('toasts.theme', name: '%{name}'),
65
+ themeSystem: ms_t('toasts.theme_system'),
66
+ themeLight: ms_t('toasts.theme_light'),
67
+ themeDark: ms_t('toasts.theme_dark'),
68
+ timeNow: ms_t('time.now'),
69
+ timeMinutes: ms_t('time.minutes', count: 0).sub('0', '%{count}')
70
+ }
71
+ end
72
+
73
+ def mailscope_datetime(time)
74
+ time.getlocal.strftime(ms_t('time.datetime'))
75
+ end
76
+
77
+ def mailscope_bytes(size)
78
+ size = size.to_i
79
+ units = %w[B KB MB GB]
80
+ index = 0
81
+ value = size.to_f
82
+ while value >= 1024 && index < units.size - 1
83
+ value /= 1024
84
+ index += 1
85
+ end
86
+ format(index.zero? ? '%d %s' : '%.1f %s', value, units[index])
87
+ end
88
+
89
+ # Accepts a Mailbox, a Message::Address or a plain string. Initials come
90
+ # from the display name when there is one, so "Equipe Impulso" beats
91
+ # "nao-responda@...".
92
+ def mailscope_avatar(source, size: :md)
93
+ mailbox = case source
94
+ when Mailbox then source
95
+ when Message::Address then Mailbox.new(address: source.address.to_s, name: source.name, count: 0)
96
+ else Mailbox.new(address: source.to_s, name: nil, count: 0)
97
+ end
98
+
99
+ tag.span(mailbox.initials,
100
+ class: "ms-avatar ms-avatar--#{size}",
101
+ style: "--avatar-hue: #{mailbox.hue}",
102
+ aria: { hidden: true })
103
+ end
104
+
105
+ def mailscope_addresses(addresses, limit: 2)
106
+ return tag.span('—', class: 'ms-muted') if addresses.empty?
107
+
108
+ shown = addresses.first(limit).map(&:short).join(', ')
109
+ rest = addresses.size - limit
110
+ rest.positive? ? "#{shown} +#{rest}" : shown
111
+ end
112
+
113
+ def mailscope_icon(name, size: 16)
114
+ tag.svg(tag.use(nil, href: "##{name}"),
115
+ class: "ms-icon ms-icon--#{name}", width: size, height: size,
116
+ viewBox: '0 0 24 24', fill: 'none', aria: { hidden: true })
117
+ end
118
+
119
+ def mailscope_filter_url(overrides = {})
120
+ base = { mailbox: params[:mailbox], q: params[:q], only: params[:only] }
121
+ root_path(base.merge(overrides).compact_blank)
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,30 @@
1
+ <!DOCTYPE html>
2
+ <html lang="pt-BR" data-theme="<%= mailscope_config.default_theme %>">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title><%= yield(:title).presence || mailscope_config.title %></title>
7
+ <meta name="robots" content="noindex, nofollow">
8
+ <%= csrf_meta_tags %>
9
+ <%= csp_meta_tag if respond_to?(:csp_meta_tag) %>
10
+ <link rel="icon" href="<%= mailscope_asset_path('favicon.svg') %>" type="image/svg+xml">
11
+ <link rel="stylesheet" href="<%= mailscope_asset_path('mailscope.css') %>">
12
+ <script nonce="<%= content_security_policy_nonce %>">
13
+ // Resolve the stored theme before first paint so there is no flash.
14
+ (function () {
15
+ try {
16
+ var stored = localStorage.getItem('mailscope:theme');
17
+ if (stored) document.documentElement.dataset.theme = stored;
18
+ } catch (e) {}
19
+ })();
20
+ </script>
21
+ </head>
22
+ <body class="ms-body" data-refresh-interval="<%= mailscope_config.auto_refresh_interval %>"
23
+ data-status-url="<%= status_path %>"
24
+ data-block-remote="<%= mailscope_config.block_remote_content %>">
25
+ <%= render 'mailscope/shared/icons' %>
26
+ <script type="application/json" data-mailscope-i18n><%= mailscope_js_strings.to_json.html_safe %></script>
27
+ <%= yield %>
28
+ <script src="<%= mailscope_asset_path('mailscope.js') %>" defer nonce="<%= content_security_policy_nonce %>"></script>
29
+ </body>
30
+ </html>
@@ -0,0 +1,9 @@
1
+ <div class="ms-empty ms-empty--pane">
2
+ <span class="ms-empty__icon ms-empty__icon--lg"><%= mailscope_icon('i-inbox', size: 36) %></span>
3
+ <p class="ms-empty__title"><%= ms_t('empty.no_selection_title') %></p>
4
+ <p class="ms-empty__text">
5
+ <%= ms_t('empty.no_selection_text_html',
6
+ keys: tag.kbd('j').concat(' / ').concat(tag.kbd('k')),
7
+ search: tag.kbd('/')) %>
8
+ </p>
9
+ </div>
@@ -0,0 +1,34 @@
1
+ <section class="ms-list" aria-label="<%= ms_t('list.label') %>">
2
+ <header class="ms-list__head">
3
+ <div class="ms-list__count">
4
+ <strong><%= @messages.size %></strong>
5
+ <%= ms_t('list.count', count: @messages.size) %>
6
+ <% if @query.filtered? %>
7
+ <a class="ms-chip ms-chip--clear" href="<%= root_path %>">
8
+ <%= ms_t('actions.clear_filters') %> <%= mailscope_icon('i-close', size: 12) %>
9
+ </a>
10
+ <% end %>
11
+ </div>
12
+ </header>
13
+
14
+ <ol class="ms-list__items" data-list role="listbox" tabindex="0"
15
+ aria-label="<%= ms_t('list.listbox') %>">
16
+ <% @messages.each do |message| %>
17
+ <%= render 'mailscope/messages/list_item', message: message, selected: @message&.id == message.id %>
18
+ <% end %>
19
+ </ol>
20
+
21
+ <% if @messages.empty? %>
22
+ <div class="ms-empty ms-empty--list">
23
+ <span class="ms-empty__icon"><%= mailscope_icon('i-inbox', size: 28) %></span>
24
+ <% if @query.filtered? %>
25
+ <p class="ms-empty__title"><%= ms_t('empty.no_results_title') %></p>
26
+ <p class="ms-empty__text"><%= ms_t('empty.no_results_text') %></p>
27
+ <a class="ms-btn ms-btn--soft" href="<%= root_path %>"><%= ms_t('actions.clear_filters_button') %></a>
28
+ <% else %>
29
+ <p class="ms-empty__title"><%= ms_t('empty.inbox_title') %></p>
30
+ <p class="ms-empty__text"><%= ms_t('empty.inbox_text') %></p>
31
+ <% end %>
32
+ </div>
33
+ <% end %>
34
+ </section>
@@ -0,0 +1,39 @@
1
+ <li class="ms-item <%= 'is-selected' if selected %>" role="option"
2
+ aria-selected="<%= selected %>" data-item data-id="<%= message.id %>">
3
+ <a class="ms-item__link" href="<%= message_path(message) %>" data-item-link>
4
+ <%= mailscope_avatar(message.from.first) %>
5
+
6
+ <div class="ms-item__body">
7
+ <div class="ms-item__row">
8
+ <span class="ms-item__from"><%= message.from.first&.short || ms_t('list.unknown_sender') %></span>
9
+ <%= mailscope_time_tag(message.sent_at) %>
10
+ </div>
11
+
12
+ <div class="ms-item__subject"><%= message.subject %></div>
13
+
14
+ <div class="ms-item__row ms-item__row--meta">
15
+ <span class="ms-item__to"><%= ms_t('list.to', list: mailscope_addresses(message.to)) %></span>
16
+ <span class="ms-item__badges">
17
+ <% if message.visible_attachments.any? %>
18
+ <span class="ms-badge" title="<%= ms_t('list.attachments', count: message.visible_attachments.size) %>">
19
+ <%= mailscope_icon('i-clip', size: 12) %><%= message.visible_attachments.size %>
20
+ </span>
21
+ <% end %>
22
+ <span class="ms-badge ms-badge--<%= message.html? ? 'html' : 'text' %>">
23
+ <%= message.html? ? 'HTML' : 'TXT' %>
24
+ </span>
25
+ </span>
26
+ </div>
27
+
28
+ <% if message.preview.present? %>
29
+ <p class="ms-item__preview"><%= truncate(message.preview, length: 110) %></p>
30
+ <% end %>
31
+ </div>
32
+ </a>
33
+
34
+ <button type="button" class="ms-item__delete" data-action="delete-item"
35
+ data-url="<%= message_path(message) %>"
36
+ title="<%= ms_t('actions.delete') %>" aria-label="<%= ms_t('actions.delete_message', subject: message.subject) %>">
37
+ <%= mailscope_icon('i-trash', size: 14) %>
38
+ </button>
39
+ </li>
@@ -0,0 +1,151 @@
1
+ <%
2
+ part_tabs = []
3
+ part_tabs << 'html' if message.html?
4
+ part_tabs << 'text' if message.text?
5
+ default_part = message.default_part
6
+ blocked = Mailscope::BodyRenderer.new(message, part: default_part, block_remote: true).blocked_resources
7
+ %>
8
+ <article class="ms-message" data-message data-id="<%= message.id %>"
9
+ data-body-url="<%= message_body_path(message) %>"
10
+ data-source-url="<%= message_source_path(message) %>">
11
+
12
+ <header class="ms-message__head">
13
+ <div class="ms-message__title">
14
+ <h1><%= message.subject %></h1>
15
+ <div class="ms-message__tools">
16
+ <a class="ms-btn ms-btn--ghost" href="<%= message_download_path(message) %>" title="<%= ms_t('actions.download') %>">
17
+ <%= mailscope_icon('i-download') %><span class="ms-sr"><%= ms_t('actions.download') %></span>
18
+ </a>
19
+ <a class="ms-btn ms-btn--ghost" href="<%= message_body_path(message, part: default_part) %>"
20
+ target="_blank" rel="noopener" title="<%= ms_t('actions.open_in_tab') %>">
21
+ <%= mailscope_icon('i-external') %><span class="ms-sr"><%= ms_t('actions.open_in_tab') %></span>
22
+ </a>
23
+ <button type="button" class="ms-btn ms-btn--ghost ms-btn--danger-ghost"
24
+ data-action="delete-item" data-url="<%= message_path(message) %>"
25
+ title="<%= ms_t('actions.delete_hint') %>">
26
+ <%= mailscope_icon('i-trash') %><span class="ms-sr"><%= ms_t('actions.delete') %></span>
27
+ </button>
28
+ </div>
29
+ </div>
30
+
31
+ <%# Reads like a message in a mail client: who sent it, to whom, when.
32
+ Everything else lives one disclosure away. %>
33
+ <div class="ms-sender">
34
+ <%= mailscope_avatar(message.from.first) %>
35
+ <div class="ms-sender__body">
36
+ <div class="ms-sender__line">
37
+ <span class="ms-sender__name"><%= message.from.first&.short || ms_t('list.unknown_sender') %></span>
38
+ <% if message.from.first&.name.present? %>
39
+ <span class="ms-sender__addr">&lt;<%= message.from.first.address %>&gt;</span>
40
+ <% end %>
41
+ <span class="ms-sender__date"><%= mailscope_datetime(message.sent_at) %></span>
42
+ </div>
43
+ <div class="ms-sender__line ms-sender__line--sub">
44
+ <span class="ms-sender__to"><%= ms_t('message.to_line', list: mailscope_addresses(message.to, limit: 3)) %></span>
45
+ </div>
46
+ <%# Everything else lives in the Headers tab, except these: Bcc is
47
+ stripped from the encoded message, and mailer and size are derived
48
+ rather than real headers. %>
49
+ <div class="ms-sender__line ms-sender__facts">
50
+ <% if message.bcc.any? %>
51
+ <span><%= ms_t('message.bcc') %> <%= mailscope_addresses(message.bcc, limit: 2) %></span>
52
+ <% end %>
53
+ <% if message.mailer.present? %><code><%= message.mailer %></code><% end %>
54
+ <span><%= mailscope_bytes(message.size) %></span>
55
+ </div>
56
+ </div>
57
+ </div>
58
+
59
+ </header>
60
+
61
+ <nav class="ms-tabs" role="tablist">
62
+ <% part_tabs.each_with_index do |value, index| %>
63
+ <button type="button" role="tab" class="ms-tab <%= 'is-active' if index.zero? %>"
64
+ data-tab="body" data-part="<%= value %>"
65
+ aria-selected="<%= index.zero? %>"><%= ms_t("tabs.#{value}") %></button>
66
+ <% end %>
67
+ <button type="button" role="tab" class="ms-tab" data-tab="source" aria-selected="false"><%= ms_t('tabs.source') %></button>
68
+ <button type="button" role="tab" class="ms-tab" data-tab="headers" aria-selected="false"><%= ms_t('tabs.headers') %></button>
69
+ <% if message.visible_attachments.any? %>
70
+ <button type="button" role="tab" class="ms-tab" data-tab="attachments" aria-selected="false">
71
+ <%= ms_t('tabs.attachments') %> <span class="ms-tab__count"><%= message.visible_attachments.size %></span>
72
+ </button>
73
+ <% end %>
74
+
75
+ <div class="ms-tabs__right" data-body-controls>
76
+ <div class="ms-segmented" role="group" aria-label="<%= ms_t('preview.widths') %>">
77
+ <% { 'desktop' => ['i-desktop', ''], 'tablet' => ['i-tablet', '768'], 'mobile' => ['i-mobile', '390'] }
78
+ .each_with_index do |(key, (icon, width)), index| %>
79
+ <% label = ms_t("preview.#{key}") %>
80
+ <button type="button" class="ms-segmented__btn <%= 'is-active' if index.zero? %>"
81
+ data-viewport="<%= width %>" title="<%= label %><%= " · #{width}px" if width.present? %>">
82
+ <%= mailscope_icon(icon, size: 15) %><span class="ms-sr"><%= label %></span>
83
+ </button>
84
+ <% end %>
85
+ </div>
86
+
87
+ <label class="ms-switch" title="<%= ms_t('preview.remote_hint') %>">
88
+ <input type="checkbox" data-remote-toggle <%= 'checked' unless mailscope_config.block_remote_content %>>
89
+ <span class="ms-switch__track" aria-hidden="true"><span class="ms-switch__thumb"></span></span>
90
+ <span class="ms-switch__label"><%= mailscope_icon('i-shield', size: 14) %> <%= ms_t('preview.remote_content') %></span>
91
+ </label>
92
+ </div>
93
+ </nav>
94
+
95
+ <% if blocked.positive? %>
96
+ <div class="ms-notice" data-remote-notice>
97
+ <%= mailscope_icon('i-shield', size: 15) %>
98
+ <span><%= ms_t('preview.blocked_html', count: blocked) %></span>
99
+ <button type="button" class="ms-link" data-action="allow-remote"><%= ms_t('actions.allow_remote') %></button>
100
+ </div>
101
+ <% end %>
102
+
103
+ <div class="ms-panels">
104
+ <div class="ms-panel is-active" data-panel="body">
105
+ <div class="ms-frame" data-frame-wrap>
106
+ <%# The src is assigned by mailscope.js so the stored "remote content"
107
+ preference is honoured on the first paint instead of causing a
108
+ second load right after it. %>
109
+ <iframe data-frame title="<%= ms_t('message.preview_label') %>"
110
+ sandbox="allow-popups allow-popups-to-escape-sandbox"></iframe>
111
+ <noscript>
112
+ <a class="ms-btn ms-btn--soft" target="_blank" rel="noopener"
113
+ href="<%= message_body_path(message, part: default_part) %>"><%= ms_t('actions.open_preview') %></a>
114
+ </noscript>
115
+ </div>
116
+ </div>
117
+
118
+ <div class="ms-panel" data-panel="source">
119
+ <pre class="ms-source" data-source-target><%= ms_t('message.loading') %></pre>
120
+ </div>
121
+
122
+ <div class="ms-panel" data-panel="headers">
123
+ <table class="ms-headers">
124
+ <tbody>
125
+ <% message.header_pairs.each do |name, value| %>
126
+ <tr><th scope="row"><%= name %></th><td><%= value %></td></tr>
127
+ <% end %>
128
+ </tbody>
129
+ </table>
130
+ </div>
131
+
132
+ <% if message.visible_attachments.any? %>
133
+ <div class="ms-panel" data-panel="attachments">
134
+ <ul class="ms-attachments">
135
+ <% message.visible_attachments.each do |file| %>
136
+ <li>
137
+ <a href="<%= message_attachment_path(message, attachment_id: file.id) %>" target="_blank" rel="noopener">
138
+ <span class="ms-attachments__ext"><%= File.extname(file.filename).delete('.').upcase.presence || '?' %></span>
139
+ <span class="ms-attachments__body">
140
+ <span class="ms-attachments__name"><%= file.filename %></span>
141
+ <span class="ms-attachments__meta"><%= file.content_type %> · <%= mailscope_bytes(file.size) %></span>
142
+ </span>
143
+ <%= mailscope_icon('i-download', size: 16) %>
144
+ </a>
145
+ </li>
146
+ <% end %>
147
+ </ul>
148
+ </div>
149
+ <% end %>
150
+ </div>
151
+ </article>
@@ -0,0 +1,33 @@
1
+ <nav class="ms-rail" aria-label="<%= ms_t('rail.label') %>">
2
+ <div class="ms-rail__section">
3
+ <h2 class="ms-rail__title"><%= ms_t('rail.heading') %></h2>
4
+
5
+ <a class="ms-box <%= 'is-active' unless @mailbox %>" href="<%= mailscope_filter_url(mailbox: nil) %>">
6
+ <span class="ms-box__icon"><%= mailscope_icon('i-inbox', size: 15) %></span>
7
+ <span class="ms-box__label"><span class="ms-box__name"><%= ms_t('rail.all') %></span></span>
8
+ <span class="ms-box__count"><%= @all.size %></span>
9
+ </a>
10
+
11
+ <% if @mailboxes.empty? %>
12
+ <p class="ms-rail__hint"><%= ms_t('rail.empty') %></p>
13
+ <% end %>
14
+
15
+ <% @mailboxes.each do |box| %>
16
+ <a class="ms-box <%= 'is-active' if @mailbox == box.address %>"
17
+ href="<%= mailscope_filter_url(mailbox: box.address) %>"
18
+ title="<%= box.address %>">
19
+ <%= mailscope_avatar(box, size: :sm) %>
20
+ <span class="ms-box__label">
21
+ <span class="ms-box__name"><%= box.label %></span>
22
+ <% if box.name.present? %><span class="ms-box__addr"><%= box.address %></span><% end %>
23
+ </span>
24
+ <span class="ms-box__count"><%= box.count %></span>
25
+ </a>
26
+ <% end %>
27
+ </div>
28
+
29
+ <footer class="ms-rail__footer">
30
+ <span class="ms-rail__version">Mailscope <%= Mailscope::VERSION %></span>
31
+ <span class="ms-rail__path" title="<%= mailscope_config.location %>"><%= mailscope_config.location.basename %>/</span>
32
+ </footer>
33
+ </nav>
@@ -0,0 +1,21 @@
1
+ <div class="ms-modal" data-shortcuts hidden>
2
+ <div class="ms-modal__backdrop" data-action="close-shortcuts"></div>
3
+ <div class="ms-modal__card" role="dialog" aria-modal="true" aria-label="<%= ms_t('shortcuts.title') %>">
4
+ <header>
5
+ <h2><%= ms_t('shortcuts.title') %></h2>
6
+ <button type="button" class="ms-btn ms-btn--ghost" data-action="close-shortcuts"
7
+ aria-label="<%= ms_t('actions.close') %>">
8
+ <%= mailscope_icon('i-close') %>
9
+ </button>
10
+ </header>
11
+ <dl class="ms-shortcuts">
12
+ <% [[%w[j ↓], 'next'], [%w[k ↑], 'previous'], [%w[/], 'search'], [%w[r], 'refresh'],
13
+ [%w[x], 'delete'], [%w[1 … 5], 'tabs'], [%w[t], 'theme'], [%w[?], 'help'], [%w[Esc], 'close']].each do |keys, key| %>
14
+ <div>
15
+ <dt><% keys.each do |k| %><%= k == '…' ? '…' : tag.kbd(k) %><% end %></dt>
16
+ <dd><%= ms_t("shortcuts.#{key}") %></dd>
17
+ </div>
18
+ <% end %>
19
+ </dl>
20
+ </div>
21
+ </div>
@@ -0,0 +1,66 @@
1
+ <% content_for :title, @all.any? ? "#{mailscope_config.title} (#{@all.size})" : mailscope_config.title %>
2
+
3
+ <div class="ms-app" data-mailscope-app data-root-path="<%= root_path %>">
4
+ <header class="ms-topbar">
5
+ <button type="button" class="ms-btn ms-btn--ghost ms-rail-toggle" data-action="rail"
6
+ aria-expanded="false" title="<%= ms_t('rail.label') %>">
7
+ <%= mailscope_icon('i-menu') %><span class="ms-sr"><%= ms_t('rail.label') %></span>
8
+ </button>
9
+
10
+ <a class="ms-brand" href="<%= root_path %>">
11
+ <span class="ms-brand__mark"><%= mailscope_icon('i-envelope', size: 15) %></span>
12
+ <span class="ms-brand__name"><%= mailscope_config.title %></span>
13
+ </a>
14
+
15
+ <form class="ms-search" method="get" action="<%= root_path %>" role="search">
16
+ <%= mailscope_icon('i-search') %>
17
+ <input type="search" name="q" value="<%= params[:q] %>" data-search-input
18
+ placeholder="<%= ms_t('search.placeholder') %>"
19
+ aria-label="<%= ms_t('search.label') %>" autocomplete="off">
20
+ <% if @mailbox %><input type="hidden" name="mailbox" value="<%= @mailbox %>"><% end %>
21
+ <kbd class="ms-search__kbd">/</kbd>
22
+ </form>
23
+
24
+ <div class="ms-topbar__actions">
25
+ <button type="button" class="ms-btn ms-btn--ghost" data-action="refresh" title="<%= ms_t('actions.refresh_hint') %>">
26
+ <%= mailscope_icon('i-refresh') %><span class="ms-sr"><%= ms_t('actions.refresh') %></span>
27
+ </button>
28
+
29
+ <label class="ms-live" title="<%= ms_t('actions.live_hint') %>">
30
+ <input type="checkbox" data-live-toggle <%= 'checked' if mailscope_config.auto_refresh %>>
31
+ <span class="ms-live__dot" aria-hidden="true"></span>
32
+ <span class="ms-live__label"><%= ms_t('actions.live') %></span>
33
+ </label>
34
+
35
+ <button type="button" class="ms-btn ms-btn--ghost" data-action="theme" title="<%= ms_t('actions.theme') %>">
36
+ <span data-theme-icon="light"><%= mailscope_icon('i-sun') %></span>
37
+ <span data-theme-icon="dark"><%= mailscope_icon('i-moon') %></span>
38
+ <span class="ms-sr"><%= ms_t('actions.theme') %></span>
39
+ </button>
40
+
41
+ <button type="button" class="ms-btn ms-btn--ghost" data-action="shortcuts" title="<%= ms_t('actions.shortcuts_hint') %>">
42
+ <%= mailscope_icon('i-keyboard') %><span class="ms-sr"><%= ms_t('actions.shortcuts') %></span>
43
+ </button>
44
+
45
+ <button type="button" class="ms-btn ms-btn--danger" data-action="clear"
46
+ data-url="<%= clear_messages_path %>" <%= 'disabled' if @all.empty? %>>
47
+ <%= mailscope_icon('i-trash') %><span><%= ms_t('actions.clear') %></span>
48
+ </button>
49
+ </div>
50
+ </header>
51
+
52
+ <div class="ms-main">
53
+ <%= render 'mailscope/messages/rail' %>
54
+ <%= render 'mailscope/messages/list' %>
55
+
56
+ <section class="ms-pane" data-pane aria-live="polite">
57
+ <% if @message %>
58
+ <%= render 'mailscope/messages/pane', message: @message %>
59
+ <% else %>
60
+ <%= render 'mailscope/messages/blank_pane' %>
61
+ <% end %>
62
+ </section>
63
+ </div>
64
+
65
+ <%= render 'mailscope/messages/shortcuts' %>
66
+ </div>