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,559 @@
1
+ // Mailscope UI. No framework, no build step — just the browser.
2
+ (function () {
3
+ 'use strict';
4
+
5
+ var body = document.body;
6
+ var app = document.querySelector('[data-mailscope-app]');
7
+ if (!app) return;
8
+
9
+ // Strings come from the server so this file stays locale-free and cacheable.
10
+ var I18N = (function () {
11
+ var node = document.querySelector('[data-mailscope-i18n]');
12
+ try { return JSON.parse(node.textContent); } catch (e) { return {}; }
13
+ })();
14
+
15
+ function t(key, values) {
16
+ var text = I18N[key] || key;
17
+ if (!values) return text;
18
+ return text.replace(/%\{(\w+)\}/g, function (_, name) { return values[name]; });
19
+ }
20
+
21
+ var STORAGE_THEME = 'mailscope:theme';
22
+ var STORAGE_REMOTE = 'mailscope:remote';
23
+ var STORAGE_LIVE = 'mailscope:live';
24
+
25
+ // ── helpers ──────────────────────────────────────────────────────────
26
+ function $(selector, root) { return (root || document).querySelector(selector); }
27
+ function $$(selector, root) { return Array.prototype.slice.call((root || document).querySelectorAll(selector)); }
28
+
29
+ function csrfToken() {
30
+ var meta = document.querySelector('meta[name="csrf-token"]');
31
+ return meta ? meta.content : '';
32
+ }
33
+
34
+ function request(url, options) {
35
+ options = options || {};
36
+ options.headers = Object.assign({
37
+ 'X-CSRF-Token': csrfToken(),
38
+ 'X-Requested-With': 'XMLHttpRequest'
39
+ }, options.headers || {});
40
+ options.credentials = 'same-origin';
41
+ return fetch(url, options);
42
+ }
43
+
44
+ function store(key, value) {
45
+ try { value === null ? localStorage.removeItem(key) : localStorage.setItem(key, value); } catch (e) { /* private mode */ }
46
+ }
47
+
48
+ function read(key) {
49
+ try { return localStorage.getItem(key); } catch (e) { return null; }
50
+ }
51
+
52
+ // ── toast ────────────────────────────────────────────────────────────
53
+ var toast = (function () {
54
+ var node = document.createElement('div');
55
+ var timer;
56
+ node.className = 'ms-toast';
57
+ node.setAttribute('role', 'status');
58
+ document.body.appendChild(node);
59
+
60
+ return function (message, action) {
61
+ node.textContent = '';
62
+ node.appendChild(document.createTextNode(message));
63
+ if (action) {
64
+ var button = document.createElement('button');
65
+ button.type = 'button';
66
+ button.textContent = action.label;
67
+ button.addEventListener('click', function () {
68
+ hide();
69
+ action.onClick();
70
+ });
71
+ node.appendChild(button);
72
+ }
73
+ node.classList.add('is-visible');
74
+ clearTimeout(timer);
75
+ timer = setTimeout(hide, action ? 9000 : 3200);
76
+ };
77
+
78
+ function hide() { node.classList.remove('is-visible'); }
79
+ })();
80
+
81
+ // ── theme ────────────────────────────────────────────────────────────
82
+ function currentTheme() { return document.documentElement.dataset.theme || 'system'; }
83
+
84
+ // What the page actually looks like right now — 'system' resolves against
85
+ // the OS setting. The toggle flips this, so one click always does what the
86
+ // icon promises instead of stepping through a three-state cycle.
87
+ function effectiveTheme() {
88
+ var theme = currentTheme();
89
+ if (theme === 'light' || theme === 'dark') return theme;
90
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
91
+ }
92
+
93
+ function applyTheme(theme) {
94
+ document.documentElement.dataset.theme = theme;
95
+ store(STORAGE_THEME, theme === 'system' ? null : theme);
96
+ onThemeChange();
97
+ }
98
+
99
+ function toggleTheme() {
100
+ var next = effectiveTheme() === 'dark' ? 'light' : 'dark';
101
+ applyTheme(next);
102
+ toast(t('theme', { name: t(next === 'dark' ? 'themeDark' : 'themeLight') }));
103
+ }
104
+
105
+ // The preview lives in a sandboxed, opaque-origin iframe, so it cannot read
106
+ // the parent's theme — it is passed along in the URL instead.
107
+ var themeListeners = [];
108
+ function onThemeChange() { themeListeners.forEach(function (fn) { fn(); }); }
109
+
110
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
111
+ if (currentTheme() === 'system') onThemeChange();
112
+ });
113
+
114
+ // ── relative timestamps ──────────────────────────────────────────────
115
+ function refreshTimes() {
116
+ var now = Date.now();
117
+ $$('time[data-relative]').forEach(function (node) {
118
+ var then = Date.parse(node.dataset.relative);
119
+ if (isNaN(then)) return;
120
+ var seconds = Math.round((now - then) / 1000);
121
+ if (seconds < 60) node.textContent = t('timeNow');
122
+ else if (seconds < 3600) node.textContent = t('timeMinutes', { count: Math.floor(seconds / 60) });
123
+ });
124
+ }
125
+
126
+ // ── list + pane loading ──────────────────────────────────────────────
127
+ var currentUrl = location.pathname + location.search;
128
+
129
+ function selectedItem() { return $('[data-item].is-selected'); }
130
+
131
+ function closeRail() {
132
+ var rail = $('.ms-rail');
133
+ if (!rail || !rail.classList.contains('is-open')) return;
134
+ rail.classList.remove('is-open');
135
+ var toggle = $('[data-action="rail"]');
136
+ if (toggle) toggle.setAttribute('aria-expanded', 'false');
137
+ }
138
+
139
+ function markSelected(id) {
140
+ $$('[data-item]').forEach(function (item) {
141
+ var on = item.dataset.id === id;
142
+ item.classList.toggle('is-selected', on);
143
+ item.setAttribute('aria-selected', on ? 'true' : 'false');
144
+ });
145
+ }
146
+
147
+ function openMessage(id, options) {
148
+ options = options || {};
149
+ var item = $('[data-item][data-id="' + id + '"]');
150
+ var link = item && $('[data-item-link]', item);
151
+ if (!link) return;
152
+
153
+ markSelected(id);
154
+ if (item.scrollIntoView) item.scrollIntoView({ block: 'nearest' });
155
+
156
+ var pane = $('[data-pane]');
157
+ pane.setAttribute('aria-busy', 'true');
158
+
159
+ request(link.href, { headers: { 'X-Mailscope-Pane': '1' } })
160
+ .then(function (r) { return r.ok ? r.text() : Promise.reject(r.status); })
161
+ .then(function (html) {
162
+ pane.innerHTML = html;
163
+ pane.removeAttribute('aria-busy');
164
+ setupPane();
165
+ if (!options.silent) {
166
+ history.pushState({ id: id }, '', link.href);
167
+ currentUrl = link.href;
168
+ }
169
+ })
170
+ .catch(function () {
171
+ pane.removeAttribute('aria-busy');
172
+ toast(t('openFailed'));
173
+ });
174
+ }
175
+
176
+ function reloadList(url, options) {
177
+ options = options || {};
178
+ url = url || currentUrl;
179
+ app.classList.add('is-busy');
180
+
181
+ return request(url)
182
+ .then(function (r) { return r.ok ? r.text() : Promise.reject(r.status); })
183
+ .then(function (html) {
184
+ var doc = new DOMParser().parseFromString(html, 'text/html');
185
+ ['.ms-list', '.ms-rail'].forEach(function (selector) {
186
+ var fresh = doc.querySelector(selector);
187
+ var stale = document.querySelector(selector);
188
+ if (fresh && stale) stale.replaceWith(fresh);
189
+ });
190
+
191
+ var clear = $('[data-action="clear"]');
192
+ var freshClear = doc.querySelector('[data-action="clear"]');
193
+ if (clear && freshClear) clear.disabled = freshClear.disabled;
194
+
195
+ if (doc.title) document.title = doc.title;
196
+
197
+ refreshTimes();
198
+
199
+ if (options.keepSelection) {
200
+ var id = options.keepSelection;
201
+ if ($('[data-item][data-id="' + id + '"]')) markSelected(id);
202
+ }
203
+ if (options.selectFirst) {
204
+ var first = $('[data-item]');
205
+ if (first) openMessage(first.dataset.id);
206
+ else renderBlankPane(doc);
207
+ }
208
+ })
209
+ .catch(function () { toast(t('listFailed')); })
210
+ .finally(function () { app.classList.remove('is-busy'); });
211
+ }
212
+
213
+ function renderBlankPane(doc) {
214
+ var blank = doc && doc.querySelector('.ms-empty--pane');
215
+ $('[data-pane]').innerHTML = blank ? blank.outerHTML :
216
+ '<div class="ms-empty ms-empty--pane"><p class="ms-empty__title"></p></div>';
217
+ if (!blank) $('[data-pane] .ms-empty__title').textContent = t('emptyTitle');
218
+ }
219
+
220
+ // ── pane behaviour (tabs, viewport, remote content) ──────────────────
221
+ function setupPane() {
222
+ var message = $('[data-message]');
223
+ if (!message) return;
224
+
225
+ var frame = $('[data-frame]', message);
226
+ var frameWrap = $('[data-frame-wrap]', message);
227
+ var bodyControls = $('[data-body-controls]', message);
228
+ var sourceLoaded = false;
229
+
230
+ function activateTab(tab) {
231
+ $$('.ms-tab', message).forEach(function (t) {
232
+ var on = t === tab;
233
+ t.classList.toggle('is-active', on);
234
+ t.setAttribute('aria-selected', on ? 'true' : 'false');
235
+ });
236
+ var name = tab.dataset.tab;
237
+ $$('.ms-panel', message).forEach(function (panel) {
238
+ panel.classList.toggle('is-active', panel.dataset.panel === name);
239
+ });
240
+ if (bodyControls) bodyControls.style.visibility = name === 'body' ? '' : 'hidden';
241
+
242
+ if (name === 'body' && tab.dataset.part) setFrameSrc({ part: tab.dataset.part });
243
+ if (name === 'source' && !sourceLoaded) loadSource();
244
+ }
245
+
246
+ function loadSource() {
247
+ sourceLoaded = true;
248
+ var target = $('[data-source-target]', message);
249
+ request(message.dataset.sourceUrl)
250
+ .then(function (r) { return r.text(); })
251
+ .then(function (text) { target.textContent = text; })
252
+ .catch(function () { target.textContent = t('sourceError'); });
253
+ }
254
+
255
+ function currentPart() {
256
+ var active = $('.ms-tab.is-active[data-part]', message);
257
+ return active ? active.dataset.part : 'html';
258
+ }
259
+
260
+ function remoteAllowed() {
261
+ var toggle = $('[data-remote-toggle]', message);
262
+ return !!(toggle && toggle.checked);
263
+ }
264
+
265
+ // Swaps in a brand new iframe instead of re-pointing the existing one:
266
+ // assigning `src` to a live iframe pushes an entry onto the session
267
+ // history, so the back button would walk through every preview you opened.
268
+ function setFrameSrc(options) {
269
+ options = options || {};
270
+ var part = options.part || currentPart();
271
+ var url = message.dataset.bodyUrl + '?part=' + encodeURIComponent(part) +
272
+ '&remote=' + (remoteAllowed() ? '1' : '0') +
273
+ '&theme=' + effectiveTheme();
274
+ if (frame.getAttribute('src') === url) return;
275
+
276
+ var fresh = document.createElement('iframe');
277
+ fresh.setAttribute('data-frame', '');
278
+ fresh.setAttribute('title', frame.getAttribute('title') || '');
279
+ fresh.setAttribute('sandbox', frame.getAttribute('sandbox') || '');
280
+ fresh.style.maxWidth = frame.style.maxWidth;
281
+ fresh.src = url;
282
+ frame.replaceWith(fresh);
283
+ frame = fresh;
284
+ }
285
+
286
+ $$('.ms-tab', message).forEach(function (tab) {
287
+ tab.addEventListener('click', function () { activateTab(tab); });
288
+ });
289
+
290
+ $$('[data-viewport]', message).forEach(function (button) {
291
+ button.addEventListener('click', function () {
292
+ $$('[data-viewport]', message).forEach(function (b) { b.classList.toggle('is-active', b === button); });
293
+ frame.style.maxWidth = button.dataset.viewport ? button.dataset.viewport + 'px' : '';
294
+ frame.dataset.viewport = button.dataset.viewport || '';
295
+ });
296
+ });
297
+
298
+ var remoteToggle = $('[data-remote-toggle]', message);
299
+ if (remoteToggle) {
300
+ var stored = read(STORAGE_REMOTE);
301
+ if (stored !== null) remoteToggle.checked = stored === '1';
302
+ remoteToggle.addEventListener('change', function () {
303
+ store(STORAGE_REMOTE, remoteToggle.checked ? '1' : '0');
304
+ setFrameSrc();
305
+ var notice = $('[data-remote-notice]', message);
306
+ if (notice) notice.hidden = remoteToggle.checked;
307
+ });
308
+ if (remoteToggle.checked) {
309
+ var notice = $('[data-remote-notice]', message);
310
+ if (notice) notice.hidden = true;
311
+ }
312
+ }
313
+
314
+ // First paint of the preview.
315
+ setFrameSrc();
316
+
317
+ // Only the plain-text view follows the theme; see BodyRenderer.
318
+ themeListeners = [function () { setFrameSrc(); }];
319
+
320
+ var allow = $('[data-action="allow-remote"]', message);
321
+ if (allow) {
322
+ allow.addEventListener('click', function () {
323
+ if (remoteToggle) { remoteToggle.checked = true; remoteToggle.dispatchEvent(new Event('change')); }
324
+ });
325
+ }
326
+ }
327
+
328
+ // ── destructive actions ──────────────────────────────────────────────
329
+ function deleteMessage(url, id) {
330
+ request(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
331
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
332
+ .then(function () {
333
+ var item = $('[data-item][data-id="' + id + '"]');
334
+ var wasSelected = item && item.classList.contains('is-selected');
335
+ var neighbour = item && (item.nextElementSibling || item.previousElementSibling);
336
+ var nextId = neighbour ? neighbour.dataset.id : null;
337
+ if (item) item.remove();
338
+ if (wasSelected) {
339
+ if (nextId) openMessage(nextId);
340
+ else renderBlankPane(null);
341
+ }
342
+ reloadList(currentUrl, { keepSelection: nextId || undefined });
343
+ toast(t('deleted'));
344
+ })
345
+ .catch(function () { toast(t('deleteFailed')); });
346
+ }
347
+
348
+ function clearAll(url) {
349
+ if (!window.confirm(t('clearConfirm'))) return;
350
+
351
+ request(url, { method: 'DELETE', headers: { Accept: 'application/json' } })
352
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
353
+ .then(function (data) {
354
+ currentUrl = app.dataset.rootPath || location.pathname;
355
+ history.replaceState({}, '', currentUrl);
356
+ return reloadList(currentUrl, { selectFirst: true }).then(function () {
357
+ toast(data.removed === 1 ? t('clearedOne') : t('clearedMany', { count: data.removed }));
358
+ });
359
+ })
360
+ .catch(function () { toast(t('clearFailed')); });
361
+ }
362
+
363
+ // ── live polling ─────────────────────────────────────────────────────
364
+ var live = (function () {
365
+ var timer = null;
366
+ var revision = null;
367
+ var interval = (parseFloat(body.dataset.refreshInterval) || 3) * 1000;
368
+
369
+ function tick() {
370
+ request(body.dataset.statusUrl, { headers: { Accept: 'application/json' } })
371
+ .then(function (r) { return r.json(); })
372
+ .then(function (data) {
373
+ if (revision === null) { revision = data.revision; return; }
374
+ if (data.revision === revision) return;
375
+ revision = data.revision;
376
+ onChange();
377
+ })
378
+ .catch(function () { /* the app may just be reloading */ });
379
+ }
380
+
381
+ function onChange() {
382
+ var selected = selectedItem();
383
+ var keep = selected ? selected.dataset.id : null;
384
+ reloadList(currentUrl, { keepSelection: keep }).then(function () {
385
+ var first = $('[data-item]');
386
+ if (!first) return;
387
+ if (keep && $('[data-item][data-id="' + keep + '"]')) {
388
+ toast(t('newMail'), { label: t('open'), onClick: function () { openMessage(first.dataset.id); } });
389
+ } else {
390
+ openMessage(first.dataset.id);
391
+ }
392
+ });
393
+ }
394
+
395
+ return {
396
+ start: function () { if (!timer) { tick(); timer = setInterval(tick, interval); } },
397
+ stop: function () { clearInterval(timer); timer = null; },
398
+ poke: function () { revision = null; }
399
+ };
400
+ })();
401
+
402
+ // ── keyboard ─────────────────────────────────────────────────────────
403
+ function moveSelection(delta) {
404
+ var items = $$('[data-item]');
405
+ if (!items.length) return;
406
+ var index = items.indexOf(selectedItem());
407
+ var next = items[Math.min(items.length - 1, Math.max(0, (index === -1 ? 0 : index + delta)))];
408
+ if (next) openMessage(next.dataset.id);
409
+ }
410
+
411
+ function typingInField(event) {
412
+ var tag = (event.target.tagName || '').toLowerCase();
413
+ return tag === 'input' || tag === 'textarea' || tag === 'select' || event.target.isContentEditable;
414
+ }
415
+
416
+ document.addEventListener('keydown', function (event) {
417
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
418
+
419
+ if (event.key === 'Escape') {
420
+ if (!$('[data-shortcuts]').hidden) { $('[data-shortcuts]').hidden = true; return; }
421
+ closeRail();
422
+ if (typingInField(event)) event.target.blur();
423
+ return;
424
+ }
425
+
426
+ if (typingInField(event)) return;
427
+
428
+ switch (event.key) {
429
+ case 'j': case 'ArrowDown': event.preventDefault(); moveSelection(1); break;
430
+ case 'k': case 'ArrowUp': event.preventDefault(); moveSelection(-1); break;
431
+ case '/': event.preventDefault(); $('[data-search-input]').focus(); $('[data-search-input]').select(); break;
432
+ case 'r': event.preventDefault(); live.poke(); reloadList(currentUrl, { keepSelection: selectedItem() && selectedItem().dataset.id }); break;
433
+ case 't': event.preventDefault(); toggleTheme(); break;
434
+ case '?': event.preventDefault(); $('[data-shortcuts]').hidden = false; break;
435
+ case 'x': {
436
+ var selected = selectedItem();
437
+ var button = selected && $('[data-action="delete-item"]', selected);
438
+ if (button) { event.preventDefault(); deleteMessage(button.dataset.url, selected.dataset.id); }
439
+ break;
440
+ }
441
+ default:
442
+ if (/^[1-5]$/.test(event.key)) {
443
+ var tabs = $$('.ms-tab');
444
+ var tab = tabs[parseInt(event.key, 10) - 1];
445
+ if (tab) { event.preventDefault(); tab.click(); }
446
+ }
447
+ }
448
+ });
449
+
450
+ // ── wiring ───────────────────────────────────────────────────────────
451
+ document.addEventListener('click', function (event) {
452
+ var link = event.target.closest('[data-item-link]');
453
+ if (link && !event.metaKey && !event.ctrlKey && event.button === 0) {
454
+ event.preventDefault();
455
+ openMessage(link.closest('[data-item]').dataset.id);
456
+ return;
457
+ }
458
+
459
+ var box = event.target.closest('.ms-box');
460
+ if (box) {
461
+ event.preventDefault();
462
+ closeRail();
463
+ currentUrl = box.getAttribute('href');
464
+ history.pushState({}, '', currentUrl);
465
+ reloadList(currentUrl, { selectFirst: true });
466
+ return;
467
+ }
468
+
469
+ var chip = event.target.closest('.ms-chip--clear');
470
+ if (chip) {
471
+ event.preventDefault();
472
+ currentUrl = chip.getAttribute('href');
473
+ history.pushState({}, '', currentUrl);
474
+ reloadList(currentUrl, { selectFirst: true });
475
+ return;
476
+ }
477
+
478
+ var action = event.target.closest('[data-action]');
479
+ if (!action) return;
480
+
481
+ switch (action.dataset.action) {
482
+ case 'refresh':
483
+ event.preventDefault();
484
+ live.poke();
485
+ reloadList(currentUrl, { keepSelection: selectedItem() && selectedItem().dataset.id });
486
+ break;
487
+ case 'theme': event.preventDefault(); toggleTheme(); break;
488
+ case 'rail': {
489
+ event.preventDefault();
490
+ var rail = $('.ms-rail');
491
+ var open = rail.classList.toggle('is-open');
492
+ action.setAttribute('aria-expanded', open ? 'true' : 'false');
493
+ break;
494
+ }
495
+ case 'shortcuts': event.preventDefault(); $('[data-shortcuts]').hidden = false; break;
496
+ case 'close-shortcuts': event.preventDefault(); $('[data-shortcuts]').hidden = true; break;
497
+ case 'clear': event.preventDefault(); clearAll(action.dataset.url); break;
498
+ case 'delete-item': {
499
+ event.preventDefault();
500
+ var item = action.closest('[data-item]');
501
+ var id = item ? item.dataset.id : action.closest('[data-message]').dataset.id;
502
+ deleteMessage(action.dataset.url, id);
503
+ break;
504
+ }
505
+ }
506
+ });
507
+
508
+ // debounced live search
509
+ (function () {
510
+ var input = $('[data-search-input]');
511
+ if (!input) return;
512
+ var form = input.closest('form');
513
+ var timer;
514
+
515
+ form.addEventListener('submit', function (event) {
516
+ event.preventDefault();
517
+ runSearch();
518
+ });
519
+
520
+ input.addEventListener('input', function () {
521
+ clearTimeout(timer);
522
+ timer = setTimeout(runSearch, 220);
523
+ });
524
+
525
+ function runSearch() {
526
+ var url = form.getAttribute('action') + '?' + new URLSearchParams(new FormData(form)).toString();
527
+ currentUrl = url;
528
+ history.replaceState({}, '', url);
529
+ reloadList(url, { selectFirst: true });
530
+ }
531
+ })();
532
+
533
+ window.addEventListener('popstate', function () {
534
+ currentUrl = location.pathname + location.search;
535
+ reloadList(currentUrl, { selectFirst: true });
536
+ });
537
+
538
+ var liveToggle = $('[data-live-toggle]');
539
+ if (liveToggle) {
540
+ // The server sets the default; whatever the user picks wins from then on.
541
+ var storedLive = read(STORAGE_LIVE);
542
+ if (storedLive !== null) liveToggle.checked = storedLive === '1';
543
+
544
+ liveToggle.addEventListener('change', function () {
545
+ store(STORAGE_LIVE, liveToggle.checked ? '1' : '0');
546
+ liveToggle.checked ? live.start() : live.stop();
547
+ });
548
+ }
549
+
550
+ document.addEventListener('visibilitychange', function () {
551
+ if (!liveToggle || !liveToggle.checked) return;
552
+ document.hidden ? live.stop() : live.start();
553
+ });
554
+
555
+ setupPane();
556
+ refreshTimes();
557
+ setInterval(refreshTimes, 30000);
558
+ if (liveToggle && liveToggle.checked) live.start();
559
+ })();
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailscope
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception, unless: -> { Rails.configuration.try(:api_only) }
6
+
7
+ layout 'mailscope/application'
8
+
9
+ before_action :authenticate_mailscope!
10
+
11
+ private
12
+
13
+ # Mailscope is frequently mounted on staging. Give people a first-class
14
+ # hook instead of relying on them remembering a routing constraint.
15
+ def authenticate_mailscope!
16
+ guard = Mailscope.config.authenticate_with
17
+ return if guard.nil?
18
+
19
+ return if instance_exec(request, &guard)
20
+
21
+ head :unauthorized
22
+ end
23
+
24
+ def mailscope_config = Mailscope.config
25
+
26
+ def storage = Mailscope.storage
27
+
28
+ helper_method :mailscope_config
29
+ end
30
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mailscope
4
+ # Serves the engine's own CSS/JS from disk with an immutable cache header.
5
+ # This keeps API-only apps working without an asset pipeline, and avoids
6
+ # letter_opener_web's habit of inlining ~277 KB of vendor code into every
7
+ # single HTML response.
8
+ class AssetsController < ApplicationController
9
+ skip_before_action :verify_authenticity_token, raise: false
10
+
11
+ ROOT = Mailscope::Engine.root.join('app', 'assets', 'mailscope')
12
+ TYPES = { '.css' => 'text/css', '.js' => 'text/javascript', '.svg' => 'image/svg+xml',
13
+ '.woff2' => 'font/woff2' }.freeze
14
+
15
+ def show
16
+ path = ROOT.join(File.basename(params[:file])).cleanpath
17
+ return head :not_found unless path.to_s.start_with?("#{ROOT}/") && path.file?
18
+
19
+ response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
20
+ send_file path, type: TYPES.fetch(path.extname, 'application/octet-stream'),
21
+ disposition: 'inline'
22
+ end
23
+ end
24
+ end