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,58 @@
|
|
|
1
|
+
<svg class="ms-sprite" aria-hidden="true" focusable="false">
|
|
2
|
+
<defs>
|
|
3
|
+
<symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
4
|
+
<circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/>
|
|
5
|
+
</symbol>
|
|
6
|
+
<symbol id="i-inbox" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
7
|
+
<path d="M4 13h4l2 3h4l2-3h4"/><path d="M5 5h14l1.5 8v4a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2v-4z"/>
|
|
8
|
+
</symbol>
|
|
9
|
+
<symbol id="i-trash" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
10
|
+
<path d="M4 7h16"/><path d="M10 11v6M14 11v6"/><path d="M6 7l1 12a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-12"/><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
|
11
|
+
</symbol>
|
|
12
|
+
<symbol id="i-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
13
|
+
<path d="M20 11a8 8 0 1 0-2.3 6.3"/><path d="M20 5v6h-6"/>
|
|
14
|
+
</symbol>
|
|
15
|
+
<symbol id="i-clip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
16
|
+
<path d="M17 8.5 9.4 16a2.8 2.8 0 0 0 4 4l7.2-7.4a4.7 4.7 0 0 0-6.6-6.6L6.3 13.7a6.6 6.6 0 0 0 9.3 9.3"/>
|
|
17
|
+
</symbol>
|
|
18
|
+
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
19
|
+
<path d="M12 4v10"/><path d="m8 11 4 4 4-4"/><path d="M5 19h14"/>
|
|
20
|
+
</symbol>
|
|
21
|
+
<symbol id="i-sun" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
22
|
+
<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>
|
|
23
|
+
</symbol>
|
|
24
|
+
<symbol id="i-moon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
25
|
+
<path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a6.8 6.8 0 0 0 10.5 10.5z"/>
|
|
26
|
+
</symbol>
|
|
27
|
+
<symbol id="i-desktop" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
28
|
+
<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>
|
|
29
|
+
</symbol>
|
|
30
|
+
<symbol id="i-mobile" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
31
|
+
<rect x="7" y="3" width="10" height="18" rx="2"/><path d="M11 18h2"/>
|
|
32
|
+
</symbol>
|
|
33
|
+
<symbol id="i-tablet" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
34
|
+
<rect x="5" y="3" width="14" height="18" rx="2"/><path d="M11 18h2"/>
|
|
35
|
+
</symbol>
|
|
36
|
+
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
37
|
+
<path d="M12 3l7 3v6c0 4.5-3 7.8-7 9-4-1.2-7-4.5-7-9V6z"/><path d="m9 12 2 2 4-4"/>
|
|
38
|
+
</symbol>
|
|
39
|
+
<symbol id="i-eye" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
40
|
+
<path d="M2 12s3.6-6 10-6 10 6 10 6-3.6 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.5"/>
|
|
41
|
+
</symbol>
|
|
42
|
+
<symbol id="i-envelope" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
43
|
+
<rect x="3" y="5.5" width="18" height="13" rx="2"/><path d="m3.4 6.6 8.6 6 8.6-6"/>
|
|
44
|
+
</symbol>
|
|
45
|
+
<symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
46
|
+
<path d="M4 7h16M4 12h16M4 17h16"/>
|
|
47
|
+
</symbol>
|
|
48
|
+
<symbol id="i-close" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
49
|
+
<path d="M6 6l12 12M18 6 6 18"/>
|
|
50
|
+
</symbol>
|
|
51
|
+
<symbol id="i-keyboard" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
52
|
+
<rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M6 14h12"/>
|
|
53
|
+
</symbol>
|
|
54
|
+
<symbol id="i-external" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
55
|
+
<path d="M14 4h6v6"/><path d="M20 4 10 14"/><path d="M18 14v5a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h5"/>
|
|
56
|
+
</symbol>
|
|
57
|
+
</defs>
|
|
58
|
+
</svg>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
en:
|
|
2
|
+
mailscope:
|
|
3
|
+
search:
|
|
4
|
+
placeholder: "Search by subject, sender or recipient…"
|
|
5
|
+
label: "Search messages"
|
|
6
|
+
actions:
|
|
7
|
+
refresh: "Refresh"
|
|
8
|
+
refresh_hint: "Refresh (r)"
|
|
9
|
+
live: "Live"
|
|
10
|
+
live_hint: "Automatically checks for newly delivered mail"
|
|
11
|
+
theme: "Toggle theme"
|
|
12
|
+
shortcuts: "Shortcuts"
|
|
13
|
+
shortcuts_hint: "Shortcuts (?)"
|
|
14
|
+
clear: "Clear"
|
|
15
|
+
clear_confirm: "Delete every captured message? This cannot be undone."
|
|
16
|
+
clear_filters: "clear filters"
|
|
17
|
+
clear_filters_button: "Clear filters"
|
|
18
|
+
delete: "Delete"
|
|
19
|
+
delete_hint: "Delete (x)"
|
|
20
|
+
delete_message: "Delete %{subject}"
|
|
21
|
+
download: "Download .eml"
|
|
22
|
+
open_in_tab: "Open in a new tab"
|
|
23
|
+
open_preview: "Open the preview"
|
|
24
|
+
allow_remote: "Load anyway"
|
|
25
|
+
close: "Close"
|
|
26
|
+
rail:
|
|
27
|
+
heading: "Mailboxes"
|
|
28
|
+
label: "Mailboxes"
|
|
29
|
+
all: "All messages"
|
|
30
|
+
empty: "No recipients yet."
|
|
31
|
+
list:
|
|
32
|
+
label: "Messages"
|
|
33
|
+
listbox: "Message list"
|
|
34
|
+
count:
|
|
35
|
+
one: "message"
|
|
36
|
+
other: "messages"
|
|
37
|
+
to: "to %{list}"
|
|
38
|
+
unknown_sender: "unknown sender"
|
|
39
|
+
attachments:
|
|
40
|
+
one: "1 attachment"
|
|
41
|
+
other: "%{count} attachments"
|
|
42
|
+
empty:
|
|
43
|
+
no_results_title: "Nothing found"
|
|
44
|
+
no_results_text: "No message matches these filters."
|
|
45
|
+
inbox_title: "Empty inbox"
|
|
46
|
+
inbox_text: "Send an email from your app and it shows up here."
|
|
47
|
+
no_selection_title: "No message selected"
|
|
48
|
+
no_selection_text_html: "Pick a message on the left, or send a new one from your app.<br>Use %{keys} to move around and %{search} to search."
|
|
49
|
+
message:
|
|
50
|
+
no_subject: "(no subject)"
|
|
51
|
+
bcc: "Bcc"
|
|
52
|
+
to_line: "to %{list}"
|
|
53
|
+
preview_label: "Message preview"
|
|
54
|
+
loading: "Loading…"
|
|
55
|
+
source_error: "Could not load the message source."
|
|
56
|
+
empty_part: "This message has no content in this version."
|
|
57
|
+
tabs:
|
|
58
|
+
html: "HTML"
|
|
59
|
+
text: "Text"
|
|
60
|
+
source: "Source"
|
|
61
|
+
headers: "Headers"
|
|
62
|
+
attachments: "Attachments"
|
|
63
|
+
preview:
|
|
64
|
+
widths: "Preview width"
|
|
65
|
+
desktop: "Desktop"
|
|
66
|
+
tablet: "Tablet"
|
|
67
|
+
mobile: "Mobile"
|
|
68
|
+
remote_content: "Remote content"
|
|
69
|
+
remote_hint: "Blocks images and media loaded from external servers"
|
|
70
|
+
blocked_html:
|
|
71
|
+
one: "<strong>1</strong> external resource blocked in this message."
|
|
72
|
+
other: "<strong>%{count}</strong> external resources blocked in this message."
|
|
73
|
+
toasts:
|
|
74
|
+
deleted: "Message deleted."
|
|
75
|
+
delete_failed: "Could not delete the message."
|
|
76
|
+
cleared:
|
|
77
|
+
one: "1 message deleted."
|
|
78
|
+
other: "%{count} messages deleted."
|
|
79
|
+
clear_failed: "Could not clear the inbox."
|
|
80
|
+
new_mail: "New mail arrived."
|
|
81
|
+
open: "Open"
|
|
82
|
+
list_failed: "Could not refresh the list."
|
|
83
|
+
open_failed: "Could not open that message."
|
|
84
|
+
theme: "Theme: %{name}"
|
|
85
|
+
theme_system: "system"
|
|
86
|
+
theme_light: "light"
|
|
87
|
+
theme_dark: "dark"
|
|
88
|
+
shortcuts:
|
|
89
|
+
title: "Shortcuts"
|
|
90
|
+
next: "Next message"
|
|
91
|
+
previous: "Previous message"
|
|
92
|
+
search: "Search"
|
|
93
|
+
refresh: "Refresh the list"
|
|
94
|
+
delete: "Delete the selected message"
|
|
95
|
+
tabs: "Switch tabs"
|
|
96
|
+
theme: "Toggle theme"
|
|
97
|
+
help: "This help"
|
|
98
|
+
close: "Close"
|
|
99
|
+
time:
|
|
100
|
+
now: "now"
|
|
101
|
+
minutes: "%{count} min"
|
|
102
|
+
datetime: "%m/%d/%Y at %H:%M:%S"
|
|
103
|
+
weekdays: [Sun, Mon, Tue, Wed, Thu, Fri, Sat]
|
|
104
|
+
storage:
|
|
105
|
+
imported: "Message imported from letter_opener"
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
pt-BR:
|
|
2
|
+
mailscope:
|
|
3
|
+
search:
|
|
4
|
+
placeholder: "Buscar por assunto, remetente ou destinatário…"
|
|
5
|
+
label: "Buscar mensagens"
|
|
6
|
+
actions:
|
|
7
|
+
refresh: "Atualizar"
|
|
8
|
+
refresh_hint: "Atualizar (r)"
|
|
9
|
+
live: "Ao vivo"
|
|
10
|
+
live_hint: "Verifica automaticamente se chegaram mensagens novas"
|
|
11
|
+
theme: "Alternar tema"
|
|
12
|
+
shortcuts: "Atalhos"
|
|
13
|
+
shortcuts_hint: "Atalhos (?)"
|
|
14
|
+
clear: "Limpar"
|
|
15
|
+
clear_confirm: "Apagar todas as mensagens capturadas? Isso não tem volta."
|
|
16
|
+
clear_filters: "limpar filtros"
|
|
17
|
+
clear_filters_button: "Limpar filtros"
|
|
18
|
+
delete: "Apagar"
|
|
19
|
+
delete_hint: "Apagar (x)"
|
|
20
|
+
delete_message: "Apagar %{subject}"
|
|
21
|
+
download: "Baixar .eml"
|
|
22
|
+
open_in_tab: "Abrir em nova aba"
|
|
23
|
+
open_preview: "Abrir a pré-visualização"
|
|
24
|
+
allow_remote: "Carregar mesmo assim"
|
|
25
|
+
close: "Fechar"
|
|
26
|
+
rail:
|
|
27
|
+
heading: "Caixas"
|
|
28
|
+
label: "Caixas de e-mail"
|
|
29
|
+
all: "Todas as mensagens"
|
|
30
|
+
empty: "Nenhum destinatário ainda."
|
|
31
|
+
list:
|
|
32
|
+
label: "Mensagens"
|
|
33
|
+
listbox: "Lista de mensagens"
|
|
34
|
+
count:
|
|
35
|
+
one: "mensagem"
|
|
36
|
+
other: "mensagens"
|
|
37
|
+
to: "para %{list}"
|
|
38
|
+
unknown_sender: "remetente desconhecido"
|
|
39
|
+
attachments:
|
|
40
|
+
one: "1 anexo"
|
|
41
|
+
other: "%{count} anexos"
|
|
42
|
+
empty:
|
|
43
|
+
no_results_title: "Nada encontrado"
|
|
44
|
+
no_results_text: "Nenhuma mensagem bate com esses filtros."
|
|
45
|
+
inbox_title: "Caixa vazia"
|
|
46
|
+
inbox_text: "Envie um e-mail pela aplicação e ele aparece aqui."
|
|
47
|
+
no_selection_title: "Nenhuma mensagem selecionada"
|
|
48
|
+
no_selection_text_html: "Escolha uma mensagem à esquerda, ou envie uma nova pela aplicação.<br>Use %{keys} para navegar e %{search} para buscar."
|
|
49
|
+
message:
|
|
50
|
+
no_subject: "(sem assunto)"
|
|
51
|
+
bcc: "Cco"
|
|
52
|
+
to_line: "para %{list}"
|
|
53
|
+
preview_label: "Pré-visualização da mensagem"
|
|
54
|
+
loading: "Carregando…"
|
|
55
|
+
source_error: "Não consegui carregar a fonte da mensagem."
|
|
56
|
+
empty_part: "Esta mensagem não tem conteúdo nesta versão."
|
|
57
|
+
tabs:
|
|
58
|
+
html: "HTML"
|
|
59
|
+
text: "Texto"
|
|
60
|
+
source: "Fonte"
|
|
61
|
+
headers: "Cabeçalhos"
|
|
62
|
+
attachments: "Anexos"
|
|
63
|
+
preview:
|
|
64
|
+
widths: "Largura do preview"
|
|
65
|
+
desktop: "Desktop"
|
|
66
|
+
tablet: "Tablet"
|
|
67
|
+
mobile: "Mobile"
|
|
68
|
+
remote_content: "Conteúdo remoto"
|
|
69
|
+
remote_hint: "Bloqueia imagens e mÃdia carregadas de servidores externos"
|
|
70
|
+
blocked_html:
|
|
71
|
+
one: "<strong>1</strong> recurso externo bloqueado nesta mensagem."
|
|
72
|
+
other: "<strong>%{count}</strong> recursos externos bloqueados nesta mensagem."
|
|
73
|
+
toasts:
|
|
74
|
+
deleted: "Mensagem apagada."
|
|
75
|
+
delete_failed: "Não consegui apagar a mensagem."
|
|
76
|
+
cleared:
|
|
77
|
+
one: "1 mensagem apagada."
|
|
78
|
+
other: "%{count} mensagens apagadas."
|
|
79
|
+
clear_failed: "Não consegui limpar a caixa."
|
|
80
|
+
new_mail: "Chegou mensagem nova."
|
|
81
|
+
open: "Abrir"
|
|
82
|
+
list_failed: "Falha ao atualizar a lista."
|
|
83
|
+
open_failed: "Não consegui abrir essa mensagem."
|
|
84
|
+
theme: "Tema: %{name}"
|
|
85
|
+
theme_system: "automático"
|
|
86
|
+
theme_light: "claro"
|
|
87
|
+
theme_dark: "escuro"
|
|
88
|
+
shortcuts:
|
|
89
|
+
title: "Atalhos"
|
|
90
|
+
next: "Próxima mensagem"
|
|
91
|
+
previous: "Mensagem anterior"
|
|
92
|
+
search: "Buscar"
|
|
93
|
+
refresh: "Atualizar a lista"
|
|
94
|
+
delete: "Apagar a mensagem selecionada"
|
|
95
|
+
tabs: "Trocar de aba"
|
|
96
|
+
theme: "Alternar tema"
|
|
97
|
+
help: "Esta ajuda"
|
|
98
|
+
close: "Fechar"
|
|
99
|
+
time:
|
|
100
|
+
now: "agora"
|
|
101
|
+
minutes: "%{count} min"
|
|
102
|
+
datetime: "%d/%m/%Y Ã s %H:%M:%S"
|
|
103
|
+
weekdays: [Dom, Seg, Ter, Qua, Qui, Sex, Sáb]
|
|
104
|
+
storage:
|
|
105
|
+
imported: "Mensagem importada do letter_opener"
|
data/config/routes.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Mailscope::Engine.routes.draw do
|
|
4
|
+
root to: 'messages#index'
|
|
5
|
+
|
|
6
|
+
get 'status', to: 'messages#status', as: :status
|
|
7
|
+
|
|
8
|
+
scope 'messages' do
|
|
9
|
+
delete '/', to: 'messages#clear', as: :clear_messages
|
|
10
|
+
|
|
11
|
+
scope ':id', constraints: { id: /[0-9a-zA-Z_-]+/ } do
|
|
12
|
+
get '/', to: 'messages#show', as: :message
|
|
13
|
+
delete '/', to: 'messages#destroy'
|
|
14
|
+
get 'body', to: 'messages#body', as: :message_body
|
|
15
|
+
get 'source', to: 'messages#source', as: :message_source
|
|
16
|
+
get 'download', to: 'messages#download', as: :message_download
|
|
17
|
+
get 'attachments/:attachment_id', to: 'messages#attachment', as: :message_attachment,
|
|
18
|
+
constraints: { attachment_id: /[0-9]+/ }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
get 'assets/:digest/:file', to: 'assets#show', as: :asset,
|
|
23
|
+
constraints: { file: /[\w-]+\.(css|js|svg|woff2)/, digest: /[0-9a-f]+/ }
|
|
24
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'cgi'
|
|
5
|
+
|
|
6
|
+
module Mailscope
|
|
7
|
+
# Builds the document that goes inside the preview iframe.
|
|
8
|
+
#
|
|
9
|
+
# The body is never rewritten with blind gsubs the way letter_opener_web
|
|
10
|
+
# did it. Inline (cid:) images become data: URIs so the frame can stay in a
|
|
11
|
+
# fully sandboxed opaque origin, and everything else is controlled with a
|
|
12
|
+
# Content-Security-Policy response header rather than by editing the markup.
|
|
13
|
+
class BodyRenderer
|
|
14
|
+
MAX_INLINE_BYTES = 2 * 1024 * 1024
|
|
15
|
+
REMOTE_ASSET = /\b(?:src|background)\s*=\s*["']?\s*https?:/i
|
|
16
|
+
REMOTE_CSS_ASSET = /url\(\s*["']?\s*https?:/i
|
|
17
|
+
URL_IN_TEXT = %r{\bhttps?://[^\s<>"')\]]+}
|
|
18
|
+
|
|
19
|
+
attr_reader :message, :part, :block_remote, :theme
|
|
20
|
+
|
|
21
|
+
def initialize(message, part: nil, block_remote: true, theme: 'light')
|
|
22
|
+
@message = message
|
|
23
|
+
@part = %w[html text].include?(part.to_s) ? part.to_s : message.default_part
|
|
24
|
+
@block_remote = block_remote
|
|
25
|
+
@theme = theme.to_s == 'dark' ? 'dark' : 'light'
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def dark? = theme == 'dark'
|
|
29
|
+
|
|
30
|
+
def document
|
|
31
|
+
<<~HTML
|
|
32
|
+
<!doctype html>
|
|
33
|
+
<html lang="pt-BR">
|
|
34
|
+
<head>
|
|
35
|
+
<meta charset="utf-8">
|
|
36
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
37
|
+
<base target="_blank">
|
|
38
|
+
<style>#{frame_styles}</style>
|
|
39
|
+
</head>
|
|
40
|
+
<body class="mailscope-frame mailscope-frame--#{part} mailscope-frame--#{theme}">#{body}</body>
|
|
41
|
+
</html>
|
|
42
|
+
HTML
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def content_security_policy
|
|
46
|
+
directives = [
|
|
47
|
+
"default-src 'none'",
|
|
48
|
+
"style-src 'unsafe-inline' data:",
|
|
49
|
+
'font-src data:',
|
|
50
|
+
"frame-src 'none'",
|
|
51
|
+
"object-src 'none'",
|
|
52
|
+
"script-src 'none'",
|
|
53
|
+
"form-action 'none'",
|
|
54
|
+
"base-uri 'none'"
|
|
55
|
+
]
|
|
56
|
+
directives << (block_remote ? 'img-src data:' : 'img-src data: https: http:')
|
|
57
|
+
directives << (block_remote ? "media-src 'none'" : 'media-src data: https: http:')
|
|
58
|
+
directives.join('; ')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# How many remote assets the current policy is holding back. Surfaced in
|
|
62
|
+
# the UI so "why is this email blank?" answers itself.
|
|
63
|
+
def blocked_resources
|
|
64
|
+
return 0 unless block_remote
|
|
65
|
+
|
|
66
|
+
@blocked_resources ||= raw_body.to_s.scan(REMOTE_ASSET).size + raw_body.to_s.scan(REMOTE_CSS_ASSET).size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def empty? = raw_body.to_s.strip.empty?
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def raw_body
|
|
74
|
+
@raw_body ||= part == 'html' ? message.html_body : message.text_body
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def body
|
|
78
|
+
if empty?
|
|
79
|
+
message = CGI.escapeHTML(Mailscope.translate('message.empty_part', 'This message has no content in this version.'))
|
|
80
|
+
return %(<p class="mailscope-empty">#{message}</p>)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
part == 'html' ? html_body : text_body
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def html_body
|
|
87
|
+
inline_cid_images(raw_body)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def text_body
|
|
91
|
+
escaped = CGI.escapeHTML(raw_body)
|
|
92
|
+
linked = escaped.gsub(URL_IN_TEXT) { |url| %(<a href="#{url}">#{url}</a>) }
|
|
93
|
+
%(<pre class="mailscope-plain">#{linked}</pre>)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def inline_cid_images(html)
|
|
97
|
+
html.gsub(/cid:([^"'\s>)]+)/i) do
|
|
98
|
+
cid = Regexp.last_match(1)
|
|
99
|
+
data_uri_for(cid) || "cid:#{cid}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def data_uri_for(cid)
|
|
104
|
+
needle = cid.delete_prefix('<').delete_suffix('>')
|
|
105
|
+
attachment = message.attachments.find { |a| a.cid.to_s.delete_prefix('<').delete_suffix('>') == needle }
|
|
106
|
+
return nil unless attachment && attachment.size.to_i <= MAX_INLINE_BYTES
|
|
107
|
+
|
|
108
|
+
bytes = message.attachment_body(attachment.id)
|
|
109
|
+
return nil unless bytes
|
|
110
|
+
|
|
111
|
+
"data:#{attachment.content_type};base64,#{Base64.strict_encode64(bytes)}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# The HTML part is deliberately left on white: it is the message exactly as
|
|
115
|
+
# its recipients will see it, and inverting it would both misrepresent the
|
|
116
|
+
# design and fight with emails that ship their own prefers-color-scheme
|
|
117
|
+
# rules. The plain-text part has no sender design to preserve, so it
|
|
118
|
+
# follows the app's theme like the Source and Headers views do.
|
|
119
|
+
def frame_styles
|
|
120
|
+
<<~CSS
|
|
121
|
+
html { background: #{page_background}; }
|
|
122
|
+
body { margin: 0; padding: 20px; color: #{page_text}; background: #{page_background};
|
|
123
|
+
font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; }
|
|
124
|
+
img { max-width: 100%; height: auto; }
|
|
125
|
+
a { color: #{link_color}; }
|
|
126
|
+
.mailscope-plain { margin: 0; white-space: pre-wrap; word-break: break-word;
|
|
127
|
+
font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
128
|
+
.mailscope-empty { color: #{muted_color}; font-style: italic; }
|
|
129
|
+
CSS
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def themed? = part == 'text' && dark?
|
|
133
|
+
|
|
134
|
+
def page_background = themed? ? '#12151c' : '#fff'
|
|
135
|
+
|
|
136
|
+
def page_text = themed? ? '#e7e9f0' : '#111'
|
|
137
|
+
|
|
138
|
+
def muted_color = themed? ? '#7b8296' : '#6b7280'
|
|
139
|
+
|
|
140
|
+
def link_color = themed? ? '#8b8cf5' : '#0b57d0'
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailscope
|
|
4
|
+
# Runtime configuration. Set through `Mailscope.configure`.
|
|
5
|
+
class Configuration
|
|
6
|
+
# Where captured mail is stored. Accepts anything Pathname() understands.
|
|
7
|
+
attr_reader :location
|
|
8
|
+
|
|
9
|
+
# Storage adapter: a symbol registered in Mailscope::Storage, or an object
|
|
10
|
+
# responding to the Mailscope::Storage::Base interface.
|
|
11
|
+
attr_accessor :storage
|
|
12
|
+
|
|
13
|
+
# Retention. Both are applied after every delivery; nil disables the check.
|
|
14
|
+
attr_accessor :max_letters, :max_age
|
|
15
|
+
|
|
16
|
+
# Callable receiving the ActionDispatch::Request. Return a falsy value to
|
|
17
|
+
# deny access. Nil means the UI is open to anyone who can reach the route.
|
|
18
|
+
attr_accessor :authenticate_with
|
|
19
|
+
|
|
20
|
+
# Default state of the "block remote content" switch in the preview pane.
|
|
21
|
+
attr_accessor :block_remote_content
|
|
22
|
+
|
|
23
|
+
# Polling for newly delivered mail, in the browser. Off by default: the
|
|
24
|
+
# user turns it on from the header and the choice sticks per browser.
|
|
25
|
+
attr_accessor :auto_refresh, :auto_refresh_interval
|
|
26
|
+
|
|
27
|
+
# :system, :light or :dark. The user can override it in the UI.
|
|
28
|
+
attr_accessor :default_theme
|
|
29
|
+
|
|
30
|
+
# Shown in the header and the <title>.
|
|
31
|
+
attr_accessor :title
|
|
32
|
+
|
|
33
|
+
# Widths offered by the viewport switcher, in CSS pixels.
|
|
34
|
+
attr_accessor :preview_widths
|
|
35
|
+
|
|
36
|
+
def initialize
|
|
37
|
+
@location = default_location
|
|
38
|
+
@storage = :filesystem
|
|
39
|
+
@max_letters = 500
|
|
40
|
+
@max_age = nil
|
|
41
|
+
@authenticate_with = nil
|
|
42
|
+
@block_remote_content = true
|
|
43
|
+
@auto_refresh = false
|
|
44
|
+
@auto_refresh_interval = 3
|
|
45
|
+
@default_theme = :system
|
|
46
|
+
@title = 'Mailscope'
|
|
47
|
+
@preview_widths = { 'Desktop' => nil, 'Tablet' => 768, 'Mobile' => 390 }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def location=(value)
|
|
51
|
+
@location = Pathname(value.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# letter_opener_web compatibility, so an existing initializer keeps working.
|
|
55
|
+
alias letters_location location
|
|
56
|
+
alias letters_location= location=
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def default_location
|
|
61
|
+
root = defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
62
|
+
Pathname(root ? root.join('tmp', 'mailscope').to_s : File.join(Dir.tmpdir, 'mailscope'))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailscope
|
|
4
|
+
# ActionMailer delivery method. Unlike letter_opener it stores the raw
|
|
5
|
+
# message plus a small metadata index, and never shells out to a browser.
|
|
6
|
+
class DeliveryMethod
|
|
7
|
+
attr_accessor :settings
|
|
8
|
+
|
|
9
|
+
def initialize(settings = {})
|
|
10
|
+
self.settings = settings
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def deliver!(mail)
|
|
14
|
+
validate!(mail)
|
|
15
|
+
|
|
16
|
+
id = storage.store(mail)
|
|
17
|
+
prune
|
|
18
|
+
id
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def storage
|
|
24
|
+
settings[:storage] || Mailscope.storage
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def prune
|
|
28
|
+
storage.prune(max_letters: Mailscope.config.max_letters, max_age: Mailscope.config.max_age)
|
|
29
|
+
rescue StandardError => e
|
|
30
|
+
Mailscope.logger.warn("[mailscope] retention pass failed: #{e.class}: #{e.message}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate!(mail)
|
|
34
|
+
if blank_envelope?(mail.smtp_envelope_from)
|
|
35
|
+
raise ArgumentError,
|
|
36
|
+
'An SMTP From address is required to deliver a message.'
|
|
37
|
+
end
|
|
38
|
+
raise ArgumentError, 'An SMTP To address is required to deliver a message.' if blank_envelope?(mail.smtp_envelope_to)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def blank_envelope?(value)
|
|
42
|
+
Array(value).reject { |v| v.to_s.strip.empty? }.empty?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mailscope/delivery_method'
|
|
4
|
+
|
|
5
|
+
module Mailscope
|
|
6
|
+
class Engine < ::Rails::Engine
|
|
7
|
+
isolate_namespace Mailscope
|
|
8
|
+
|
|
9
|
+
initializer 'mailscope.delivery_method' do
|
|
10
|
+
ActiveSupport.on_load :action_mailer do
|
|
11
|
+
ActionMailer::Base.add_delivery_method(:mailscope, Mailscope::DeliveryMethod)
|
|
12
|
+
# Accept the old name too, so a fork migration is a one-line change.
|
|
13
|
+
ActionMailer::Base.add_delivery_method(:letter_opener_web, Mailscope::DeliveryMethod)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
initializer 'mailscope.default_location' do
|
|
18
|
+
Mailscope.config.location = Rails.root.join('tmp', 'mailscope') if Mailscope.config.location.to_s.empty?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Mailscope
|
|
4
|
+
# A recipient address, with the messages that landed in it. This is what the
|
|
5
|
+
# left rail lists: one entry per "caixa de e-mail".
|
|
6
|
+
# rubocop:disable Lint/StructNewOverride -- `count` reads better than any
|
|
7
|
+
# alternative here, and this struct is never used as an enumerable.
|
|
8
|
+
Mailbox = Struct.new(:address, :name, :count, :latest_at, keyword_init: true) do
|
|
9
|
+
def label = name.to_s.empty? ? address : name
|
|
10
|
+
|
|
11
|
+
def initials
|
|
12
|
+
source = label.to_s
|
|
13
|
+
letters = source.scan(/[[:alnum:]]+/).first(2).map { |w| w[0] }.join
|
|
14
|
+
(letters.empty? ? source[0, 2] : letters).to_s.upcase
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Stable colour per mailbox, so the same address always looks the same.
|
|
18
|
+
def hue = address.to_s.each_byte.sum % 360
|
|
19
|
+
|
|
20
|
+
def to_h = { address:, name:, count:, latest_at: latest_at&.iso8601 }
|
|
21
|
+
|
|
22
|
+
def self.index(messages)
|
|
23
|
+
buckets = {}
|
|
24
|
+
messages.each { |message| message.recipients.each { |addr| absorb(buckets, addr, message) } }
|
|
25
|
+
buckets.values.sort_by { |box| [-box.latest_at.to_i, box.address] }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.absorb(buckets, addr, message)
|
|
29
|
+
key = addr.address.to_s.downcase
|
|
30
|
+
return if key.empty?
|
|
31
|
+
|
|
32
|
+
box = buckets[key] ||= new(address: key, name: addr.name, count: 0, latest_at: nil)
|
|
33
|
+
box.name ||= addr.name
|
|
34
|
+
box.count += 1
|
|
35
|
+
box.latest_at = message.sent_at if box.latest_at.nil? || message.sent_at > box.latest_at
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
# rubocop:enable Lint/StructNewOverride
|
|
39
|
+
end
|