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,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