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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +68 -0
- data/LICENSE.txt +27 -0
- data/README.md +210 -0
- data/app/assets/mailscope/INTER-LICENSE.txt +92 -0
- data/app/assets/mailscope/favicon.svg +6 -0
- data/app/assets/mailscope/inter-latin-ext.woff2 +0 -0
- data/app/assets/mailscope/inter-latin.woff2 +0 -0
- data/app/assets/mailscope/mailscope.css +594 -0
- data/app/assets/mailscope/mailscope.js +559 -0
- data/app/controllers/mailscope/application_controller.rb +30 -0
- data/app/controllers/mailscope/assets_controller.rb +24 -0
- data/app/controllers/mailscope/messages_controller.rb +139 -0
- data/app/helpers/mailscope/application_helper.rb +124 -0
- data/app/views/layouts/mailscope/application.html.erb +30 -0
- data/app/views/mailscope/messages/_blank_pane.html.erb +9 -0
- data/app/views/mailscope/messages/_list.html.erb +34 -0
- data/app/views/mailscope/messages/_list_item.html.erb +39 -0
- data/app/views/mailscope/messages/_pane.html.erb +151 -0
- data/app/views/mailscope/messages/_rail.html.erb +33 -0
- data/app/views/mailscope/messages/_shortcuts.html.erb +21 -0
- data/app/views/mailscope/messages/index.html.erb +66 -0
- data/app/views/mailscope/shared/_icons.html.erb +58 -0
- data/config/locales/mailscope.en.yml +105 -0
- data/config/locales/mailscope.pt-BR.yml +105 -0
- data/config/routes.rb +24 -0
- data/lib/mailscope/body_renderer.rb +142 -0
- data/lib/mailscope/configuration.rb +65 -0
- data/lib/mailscope/delivery_method.rb +45 -0
- data/lib/mailscope/engine.rb +21 -0
- data/lib/mailscope/mailbox.rb +39 -0
- data/lib/mailscope/message.rb +268 -0
- data/lib/mailscope/query.rb +54 -0
- data/lib/mailscope/storage/base.rb +35 -0
- data/lib/mailscope/storage/filesystem.rb +214 -0
- data/lib/mailscope/storage.rb +23 -0
- data/lib/mailscope/version.rb +5 -0
- data/lib/mailscope.rb +60 -0
- 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"><<%= message.from.first.address %>></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>
|