rails_email 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d450b660f8baa8e70bc9d9782b1ac175b433f56980805bd27c6249364afa5ee1
4
+ data.tar.gz: 80d17908c3b8956837271e69c1f8254ec3772b5f8f58211311a643da598703a6
5
+ SHA512:
6
+ metadata.gz: 847d2b04921e10073d2ec4ec29423109454886d86b67222f0a41c31df5e8ae87e94fcf3a1a91a340866c73908cedc8c6fd4abd020be5a91c59763265f8381485
7
+ data.tar.gz: c852dcfce91392fd8e55483077764e0da719323fe7daee0a5f38312babe14e4580655c128619cb981363234cb8250e09056d708ca86556d7db9ad59011cacbdc
data/README.md ADDED
@@ -0,0 +1,159 @@
1
+ # RailsEmail
2
+
3
+ A polished mailer preview UI for Rails — inspired by [React Email](https://react.email). Drop it in and your `/rails/mailers` preview route gets a modern dark/light interface with viewport controls, source inspection, CSS compatibility checks, and spam scoring.
4
+
5
+ Zero configuration. No build step. Works with your existing `ActionMailer::Preview` classes.
6
+
7
+ ---
8
+
9
+ ## Features
10
+
11
+ - **Dark & light theme** — toggle anytime, preference saved in localStorage
12
+ - **Dark mode email rendering** — LCH-based color inversion (same algorithm as React Email)
13
+ - **Viewport presets** — Mobile (375px), Tablet (768px), Desktop (1280px), Email (600px), or custom
14
+ - **Source viewer** — toggle between rendered preview and raw HTML / plain-text source
15
+ - **Headers panel** — view From, To, Subject, Date, Reply-To, CC, BCC at a glance
16
+ - **CSS compatibility checker** — detects unsupported properties (Flexbox, Grid, CSS variables, animations…) with the affected clients listed
17
+ - **Spam score** — runs your email through Postmark's SpamAssassin API and shows a score out of 10
18
+ - **Download as .eml** — grab the full RFC 822 message for local testing
19
+ - **Locale selector** — switch preview locale without leaving the page
20
+ - **Collapsible sidebar** — more room for your email when you need it
21
+
22
+ ---
23
+
24
+ ## Requirements
25
+
26
+ - Ruby >= 3.1
27
+ - Rails >= 7.0
28
+ - ActionMailer previews enabled (`config.action_mailer.show_previews = true` in development)
29
+
30
+ ---
31
+
32
+ ## Installation
33
+
34
+ Add to your `Gemfile`:
35
+
36
+ ```ruby
37
+ gem "rails_email"
38
+ ```
39
+
40
+ Then run:
41
+
42
+ ```
43
+ bundle install
44
+ ```
45
+
46
+ That's it. No generators, no initializers, no configuration files needed.
47
+
48
+ > **Note:** Make sure `config.action_mailer.show_previews = true` is set in `config/environments/development.rb`. Rails enables this by default in development, so you probably don't need to touch anything.
49
+
50
+ ---
51
+
52
+ ## Usage
53
+
54
+ Start your Rails server and visit:
55
+
56
+ ```
57
+ http://localhost:3000/rails/mailers
58
+ ```
59
+
60
+ Your existing `ActionMailer::Preview` classes appear in the left sidebar. Click any email action to open the preview.
61
+
62
+ ### Preview classes
63
+
64
+ RailsEmail works with standard ActionMailer previews. Place them in `test/mailers/previews/` (or wherever `config.action_mailer.preview_paths` points):
65
+
66
+ ```ruby
67
+ # test/mailers/previews/user_mailer_preview.rb
68
+ class UserMailerPreview < ActionMailer::Preview
69
+ def welcome_email
70
+ UserMailer.with(user: User.first).welcome_email
71
+ end
72
+
73
+ def password_reset
74
+ UserMailer.with(user: User.first).password_reset
75
+ end
76
+ end
77
+ ```
78
+
79
+ ### Spam check
80
+
81
+ The Spam tab sends the full RFC 822 message (headers + body) to [Postmark's SpamAssassin API](https://spamcheck.postmarkapp.com). No API key required. An internet connection is needed when running the check.
82
+
83
+ The score is displayed as **X / 10** (higher is better). The individual SpamAssassin rules that fired are shown below the score, sorted by impact.
84
+
85
+ ---
86
+
87
+ ## Tabs reference
88
+
89
+ | Tab | What it shows |
90
+ |---|---|
91
+ | **Headers** | From, To, Subject, Date, Reply-To, CC, BCC; locale switcher |
92
+ | **Compatibility** | CSS properties in the email that are unsupported by major clients |
93
+ | **Spam** | SpamAssassin score and the rules that contributed to it |
94
+ | **Attachments** | Files attached to the email with download links |
95
+
96
+ ---
97
+
98
+ ## Keyboard / UI controls
99
+
100
+ | Control | Action |
101
+ |---|---|
102
+ | Sidebar toggle (☰) | Collapse / expand the mailer list |
103
+ | Preview / Source buttons | Switch between rendered and raw views |
104
+ | Desktop / Mobile buttons | Switch between full-width and 375 px mobile viewport |
105
+ | Viewport dropdown | Fine-grained presets: Mobile, Tablet, Desktop, Email (600 px), Custom |
106
+ | Theme toggle (☀/☾) | Switch dark ↔ light; saved across sessions |
107
+ | ↓ Download | Download the email as an `.eml` file |
108
+ | ∨ (panel header) | Collapse / expand the bottom info panel |
109
+
110
+ ---
111
+
112
+ ## How it works
113
+
114
+ RailsEmail is a Rails Engine that:
115
+
116
+ 1. Prepends its own views to `Rails::MailersController`, replacing the default preview templates without touching the controller itself.
117
+ 2. Adds a single new route — `GET /rails/mailers/spam_check/*path` — prepended before ActionMailer's wildcard so it is matched first.
118
+ 3. Includes a controller extension that handles the spam check request: it calls your preview, serialises the result to a full RFC 822 string via `email.to_s`, and forwards it to Postmark's SpamAssassin API over TLS.
119
+
120
+ No monkey-patching, no middleware, no asset pipeline involvement. Everything is rendered as inline ERB (CSS and JS embedded in partials).
121
+
122
+ ---
123
+
124
+ ## Development
125
+
126
+ Clone the repo and install dependencies:
127
+
128
+ ```bash
129
+ git clone https://github.com/your-org/rails_email.git
130
+ cd rails_email
131
+ bundle install
132
+ ```
133
+
134
+ The `test/dummy` directory contains a minimal Rails app with sample mailers. Run it with:
135
+
136
+ ```bash
137
+ cd test/dummy
138
+ bundle exec rails server
139
+ ```
140
+
141
+ Then open `http://localhost:3000/rails/mailers`.
142
+
143
+ ---
144
+
145
+ ## Contributing
146
+
147
+ 1. Fork the repository
148
+ 2. Create a feature branch (`git checkout -b my-feature`)
149
+ 3. Commit your changes (`git commit -m 'Add my feature'`)
150
+ 4. Push to the branch (`git push origin my-feature`)
151
+ 5. Open a pull request
152
+
153
+ Please keep changes focused. Bug fixes and improvements to existing features are welcome. For larger changes, open an issue first to discuss the approach.
154
+
155
+ ---
156
+
157
+ ## License
158
+
159
+ MIT. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,344 @@
1
+ <%# colorjs for dark-mode color inversion — same approach as React Email %>
2
+ <script src="https://cdn.jsdelivr.net/npm/colorjs.io/dist/color.legacy.min.js"></script>
3
+
4
+ <script>
5
+ (function () {
6
+
7
+ // ── Theme ────────────────────────────────────────────────
8
+ var savedTheme = localStorage.getItem("rm_theme") || "dark";
9
+ document.documentElement.dataset.theme = savedTheme;
10
+
11
+ window.rmToggleTheme = function () {
12
+ var html = document.documentElement;
13
+ var isDark = html.dataset.theme === "dark";
14
+ html.dataset.theme = isDark ? "light" : "dark";
15
+ localStorage.setItem("rm_theme", isDark ? "light" : "dark");
16
+ var moon = document.getElementById("rm-icon-moon");
17
+ var sun = document.getElementById("rm-icon-sun");
18
+ if (moon) moon.classList.toggle("hidden", isDark);
19
+ if (sun) sun.classList.toggle("hidden", !isDark);
20
+ rmApplyDarkMode(!isDark);
21
+ };
22
+
23
+ document.addEventListener("DOMContentLoaded", function () {
24
+ if (savedTheme === "light") {
25
+ var moon = document.getElementById("rm-icon-moon");
26
+ var sun = document.getElementById("rm-icon-sun");
27
+ if (moon) moon.classList.add("hidden");
28
+ if (sun) sun.classList.remove("hidden");
29
+ }
30
+ // Apply dark mode inversion on initial load if dark theme active
31
+ var iframe = document.getElementById("rm-email-frame");
32
+ if (iframe) {
33
+ iframe.addEventListener("load", function () {
34
+ if (document.documentElement.dataset.theme === "dark") {
35
+ rmApplyDarkMode(true);
36
+ }
37
+ });
38
+ }
39
+ });
40
+
41
+ // ── Dark mode: colorjs LCH inversion (React Email algorithm) ─
42
+ function* walkDom(node) {
43
+ if (node.nodeType === 1) yield node;
44
+ for (var c of Array.from(node.childNodes)) yield* walkDom(c);
45
+ }
46
+
47
+ function invertLch(colorStr, isBg) {
48
+ if (!colorStr || colorStr === "transparent" || colorStr === "rgba(0, 0, 0, 0)") return null;
49
+ try {
50
+ var c = new Color(colorStr).to("lch");
51
+ var l = c.l;
52
+ var newL = isBg
53
+ ? (l >= 50 ? 50 - (l - 50) * 0.75 : 50 + (50 - l) * 0.75)
54
+ : (l < 50 ? 50 + (50 - l) * 0.75 : 50 - (l - 50) * 0.75);
55
+ c.l = Math.max(0, Math.min(100, newL));
56
+ c.c = Math.max(0, c.c * 0.8);
57
+ return c.to("srgb").toString({ format: "hex" });
58
+ } catch (e) { return null; }
59
+ }
60
+
61
+ window.rmApplyDarkMode = function (isDark) {
62
+ var iframe = document.getElementById("rm-email-frame");
63
+ if (!iframe || !iframe.contentDocument || !iframe.contentDocument.body) return;
64
+ var doc = iframe.contentDocument;
65
+
66
+ if (!isDark) {
67
+ // Restore originals
68
+ for (var el of walkDom(doc.body)) {
69
+ if (el.dataset.rmBg !== undefined) { el.style.backgroundColor = el.dataset.rmBg; delete el.dataset.rmBg; }
70
+ if (el.dataset.rmFg !== undefined) { el.style.color = el.dataset.rmFg; delete el.dataset.rmFg; }
71
+ }
72
+ doc.documentElement.style.colorScheme = "";
73
+ return;
74
+ }
75
+
76
+ doc.documentElement.style.colorScheme = "dark";
77
+
78
+ if (typeof Color === "undefined") return; // CDN not loaded
79
+
80
+ var view = doc.defaultView || iframe.contentWindow;
81
+ for (var el of walkDom(doc.body)) {
82
+ var s = view.getComputedStyle(el);
83
+
84
+ var bg = s.backgroundColor;
85
+ if (bg && bg !== "rgba(0, 0, 0, 0)") {
86
+ el.dataset.rmBg = el.style.backgroundColor || "";
87
+ var invBg = invertLch(bg, true);
88
+ if (invBg) el.style.backgroundColor = invBg;
89
+ }
90
+
91
+ var fg = s.color;
92
+ if (fg && fg !== "rgba(0, 0, 0, 0)") {
93
+ el.dataset.rmFg = el.style.color || "";
94
+ var invFg = invertLch(fg, false);
95
+ if (invFg) el.style.color = invFg;
96
+ }
97
+ }
98
+ };
99
+
100
+ // ── Sidebar ──────────────────────────────────────────────
101
+ window.rmToggleSidebar = function () {
102
+ var s = document.getElementById("rm-sidebar");
103
+ if (s) s.classList.toggle("collapsed");
104
+ };
105
+
106
+ // ── View (preview ↔ source) ──────────────────────────────
107
+ window.rmSetView = function (view) {
108
+ var vP = document.getElementById("rm-view-preview");
109
+ var vS = document.getElementById("rm-view-source");
110
+ var bP = document.getElementById("rm-btn-preview");
111
+ var bS = document.getElementById("rm-btn-source");
112
+ if (vP) vP.classList.toggle("hidden", view !== "preview");
113
+ if (vS) vS.classList.toggle("hidden", view !== "source");
114
+ if (bP) bP.classList.toggle("active", view === "preview");
115
+ if (bS) bS.classList.toggle("active", view === "source");
116
+ };
117
+
118
+ // ── Viewport size ────────────────────────────────────────
119
+ var _vpOpen = false;
120
+
121
+ window.rmToggleViewportDropdown = function (e) {
122
+ e.stopPropagation();
123
+ _vpOpen = !_vpOpen;
124
+ var p = document.getElementById("rm-viewport-panel");
125
+ if (p) p.classList.toggle("hidden", !_vpOpen);
126
+ };
127
+
128
+ document.addEventListener("click", function (e) {
129
+ if (!e.target.closest("#rm-size-group")) {
130
+ var p = document.getElementById("rm-viewport-panel");
131
+ if (p) p.classList.add("hidden");
132
+ _vpOpen = false;
133
+ }
134
+ });
135
+
136
+ window.rmApplyPreset = function (name, w, h) {
137
+ var iframe = document.getElementById("rm-email-frame");
138
+ var stage = document.getElementById("rm-stage");
139
+ if (!iframe) return;
140
+
141
+ // Width
142
+ if (w) {
143
+ iframe.style.width = w + "px";
144
+ iframe.style.maxWidth = w + "px";
145
+ if (stage) stage.style.alignItems = "flex-start";
146
+ } else {
147
+ iframe.style.width = "100%";
148
+ iframe.style.maxWidth = "";
149
+ if (stage) stage.style.alignItems = "";
150
+ }
151
+
152
+ // Height
153
+ if (h) {
154
+ iframe.style.height = h + "px";
155
+ } else {
156
+ iframe.style.height = "100%";
157
+ }
158
+
159
+ // Update preset active states
160
+ document.querySelectorAll(".rm-vp-preset").forEach(function (btn) {
161
+ btn.classList.toggle("rm-vp-active", btn.dataset.preset === name);
162
+ });
163
+
164
+ // Sync desktop / mobile quick buttons
165
+ var bD = document.getElementById("rm-btn-desktop");
166
+ var bM = document.getElementById("rm-btn-mobile");
167
+ if (bD) bD.classList.toggle("active", !w);
168
+ if (bM) bM.classList.toggle("active", w === 375);
169
+
170
+ localStorage.setItem("rm_vp", JSON.stringify({ name: name, w: w, h: h }));
171
+
172
+ // Close dropdown
173
+ var panel = document.getElementById("rm-viewport-panel");
174
+ if (panel) panel.classList.add("hidden");
175
+ _vpOpen = false;
176
+ };
177
+
178
+ window.rmApplyCustomViewport = function () {
179
+ var wv = parseInt(document.getElementById("rm-vp-w").value, 10) || null;
180
+ var hv = parseInt(document.getElementById("rm-vp-h").value, 10) || null;
181
+ rmApplyPreset("Custom", wv, hv);
182
+ };
183
+
184
+ window.rmSetSize = function (size) {
185
+ if (size === "mobile") rmApplyPreset("Mobile", 375, null);
186
+ else rmApplyPreset("Full", null, null);
187
+ };
188
+
189
+ // Restore saved viewport
190
+ document.addEventListener("DOMContentLoaded", function () {
191
+ var saved = localStorage.getItem("rm_vp");
192
+ if (saved) { try { var vp = JSON.parse(saved); rmApplyPreset(vp.name, vp.w, vp.h); } catch (e) {} }
193
+ });
194
+
195
+ // ── Source sub-tabs ──────────────────────────────────────
196
+ window.rmSetSourceTab = function (id, btn) {
197
+ document.querySelectorAll(".rm-source-body").forEach(function (el) { el.classList.add("hidden"); });
198
+ document.querySelectorAll(".rm-source-tab").forEach(function (el) { el.classList.remove("active"); });
199
+ var el = document.getElementById(id);
200
+ if (el) el.classList.remove("hidden");
201
+ btn.classList.add("active");
202
+ };
203
+
204
+ // ── Bottom panel tabs ────────────────────────────────────
205
+ window.rmSetBottomTab = function (id, btn) {
206
+ document.querySelectorAll(".rm-tab-content").forEach(function (el) { el.classList.remove("active"); });
207
+ document.querySelectorAll(".rm-bottom-tab").forEach(function (el) { el.classList.remove("active"); });
208
+ var el = document.getElementById(id);
209
+ if (el) el.classList.add("active");
210
+ btn.classList.add("active");
211
+ };
212
+
213
+ // ── Bottom panel collapse ────────────────────────────────
214
+ var _panelOpen = true;
215
+ window.rmTogglePanel = function () {
216
+ _panelOpen = !_panelOpen;
217
+ var body = document.getElementById("rm-panel-body");
218
+ var icon = document.getElementById("rm-collapse-icon");
219
+ if (body) body.classList.toggle("hidden", !_panelOpen);
220
+ if (icon) icon.style.transform = _panelOpen ? "" : "rotate(180deg)";
221
+ };
222
+
223
+ // ── Compatibility check ──────────────────────────────────
224
+ var RM_COMPAT = [
225
+ { prop: "border-radius", rx: /border-radius/i, clients: "Outlook 2007–21, Outlook.com" },
226
+ { prop: "box-sizing", rx: /box-sizing/i, clients: "Gmail, Outlook 2007–21, Yahoo Mail" },
227
+ { prop: "overflow", rx: /\boverflow\s*:/i, clients: "Outlook 2007–21" },
228
+ { prop: "word-break", rx: /word-break/i, clients: "Outlook 2007–21, Yahoo Mail" },
229
+ { prop: "display: flex", rx: /display\s*:\s*flex/i, clients: "Outlook 2007–21, Gmail (webmail)" },
230
+ { prop: "display: grid", rx: /display\s*:\s*grid/i, clients: "Outlook 2007–21, Gmail, Yahoo Mail" },
231
+ { prop: "CSS variables", rx: /var\s*\(/i, clients: "Outlook 2007–21, Gmail, Yahoo Mail" },
232
+ { prop: "position (fixed)", rx: /position\s*:\s*(fixed|sticky)/i, clients: "Gmail, Outlook.com, Yahoo Mail" },
233
+ { prop: "opacity", rx: /\bopacity\s*:/i, clients: "Outlook 2007–16" },
234
+ { prop: "text-shadow", rx: /text-shadow/i, clients: "Outlook 2007–21" },
235
+ { prop: "animation", rx: /\banimation\b/i, clients: "Outlook 2007–21, Gmail, Yahoo Mail" },
236
+ { prop: "transform", rx: /\btransform\s*:/i, clients: "Outlook 2007–21, Gmail" },
237
+ { prop: "filter", rx: /\bfilter\s*:/i, clients: "Outlook 2007–21, Gmail" },
238
+ { prop: "background-size", rx: /background-size/i, clients: "Outlook 2007–21" },
239
+ { prop: "min-height", rx: /min-height/i, clients: "Outlook 2007–21" },
240
+ { prop: "object-fit", rx: /object-fit/i, clients: "Outlook 2007–21, Gmail" },
241
+ { prop: "letter-spacing", rx: /letter-spacing/i, clients: "Outlook 2007–21" },
242
+ { prop: "@font-face", rx: /@font-face/i, clients: "Gmail, Yahoo Mail, Outlook 2007–21" },
243
+ { prop: "@media queries", rx: /@media/i, clients: "Gmail (webmail), Outlook 2007–03" },
244
+ ];
245
+
246
+ window.rmRunCompatibilityCheck = function () {
247
+ var box = document.getElementById("rm-compat-content");
248
+ if (!box) return;
249
+
250
+ var iframe = document.getElementById("rm-email-frame");
251
+ if (!iframe || !iframe.contentDocument || !iframe.contentDocument.body) {
252
+ box.innerHTML = '<p class="rm-check-error">No email loaded.</p>';
253
+ return;
254
+ }
255
+
256
+ var doc = iframe.contentDocument;
257
+ var css = Array.from(doc.querySelectorAll("style")).map(function (s) { return s.textContent || ""; }).join("\n");
258
+ var inl = Array.from(doc.querySelectorAll("[style]")).map(function (el) { return el.getAttribute("style") || ""; }).join("\n");
259
+ var text = css + "\n" + inl;
260
+
261
+ var issues = RM_COMPAT.filter(function (r) { return r.rx.test(text); });
262
+
263
+ if (issues.length === 0) {
264
+ box.innerHTML = '<div class="rm-check-ok"><div class="rm-check-ok-icon">✓</div><strong>Great compatibility</strong><p>No known issues detected.</p></div>';
265
+ return;
266
+ }
267
+
268
+ var rows = issues.map(function (r) {
269
+ return "<tr><td class='rm-compat-prop'>⚠ " + r.prop + "</td><td class='rm-compat-clients'>Not supported in " + r.clients + "</td></tr>";
270
+ }).join("");
271
+ box.innerHTML = "<table class='rm-compat-table'>" + rows + "</table>";
272
+ };
273
+
274
+ // ── Spam check (Postmark / SpamAssassin) ─────────────────
275
+ window.rmRunSpamCheck = function (path) {
276
+ var box = document.getElementById("rm-spam-content");
277
+ if (!box) return;
278
+ box.innerHTML = '<div class="rm-check-loading"><span>Checking…</span></div>';
279
+
280
+ fetch("/rails/mailers/spam_check/" + path)
281
+ .then(function (r) { return r.json(); })
282
+ .then(function (data) {
283
+ if (data.error) {
284
+ box.innerHTML = '<p class="rm-check-error">⚠ ' + data.error + '</p>';
285
+ return;
286
+ }
287
+
288
+ var score = parseFloat(data.score) || 0;
289
+ var displayScore = Math.round(Math.max(0, 10 - score) * 10) / 10;
290
+ var iconClass = score < 1.5 ? "" : score < 3 ? "rm-check-ok-icon--warn" : "rm-check-ok-icon--danger";
291
+ var label = score < 1.5 ? "Clean email" : score < 3 ? "Low spam risk" : "Spam issues detected";
292
+
293
+ var header = '<div class="rm-check-ok">' +
294
+ '<div class="rm-check-ok-icon ' + iconClass + '">' + displayScore + '/10</div>' +
295
+ '<strong>' + label + '</strong>' +
296
+ '<p>SpamAssassin score: ' + score + '</p>' +
297
+ '</div>';
298
+
299
+ var rows = "";
300
+ if (data.rules && data.rules.length) {
301
+ var sorted = data.rules.slice().sort(function (a, b) { return parseFloat(b.score) - parseFloat(a.score); });
302
+ var actionable = sorted.filter(function (r) {
303
+ var desc = (r.description || r.name || "");
304
+ return !desc.startsWith("ADMINISTRATOR NOTICE") &&
305
+ !desc.startsWith("Informational:");
306
+ });
307
+ rows = "<table class='rm-spam-table'>" +
308
+ actionable.map(function (r) {
309
+ return "<tr><td class='rm-spam-points'>" + r.score + "</td><td class='rm-spam-desc'>" + (r.description || r.name || "") + "</td></tr>";
310
+ }).join("") +
311
+ "</table>";
312
+ }
313
+
314
+ box.innerHTML = header + rows;
315
+ })
316
+ .catch(function () {
317
+ box.innerHTML = '<p class="rm-check-error">Spam check unavailable — no internet connection.</p>';
318
+ });
319
+ };
320
+
321
+ // ── Rails-compatible iframe refresh ─────────────────────
322
+ window.refreshBody = function (reload) {
323
+ var partSel = document.querySelector("select#part");
324
+ var localeSel = document.querySelector("select#locale");
325
+ var iframe = document.getElementById("rm-email-frame");
326
+ if (!iframe) return;
327
+
328
+ var partParam = partSel
329
+ ? partSel.options[partSel.selectedIndex].value
330
+ : (document.querySelector("#mime_type") ? document.querySelector("#mime_type").dataset.mimeType : "");
331
+ var localeParam = localeSel ? localeSel.options[localeSel.selectedIndex].value : null;
332
+ var fresh = localeParam ? ("?" + partParam + "&" + localeParam) : ("?" + partParam);
333
+
334
+ iframe.contentWindow.location = fresh;
335
+
336
+ var url = location.pathname.replace(/\.(txt|html)$/, "");
337
+ var fmt = /html/.test(partParam) ? ".html" : ".txt";
338
+ var state = localeParam ? (url + fmt + "?" + localeParam) : (url + fmt);
339
+ if (reload) location.href = state;
340
+ else if (history.replaceState) history.replaceState({}, "", state);
341
+ };
342
+
343
+ }());
344
+ </script>
@@ -0,0 +1,26 @@
1
+ <%
2
+ _all_previews = ActionMailer::Preview.all
3
+ _active_preview = local_assigns.fetch(:active_preview, nil)
4
+ _active_action = local_assigns.fetch(:active_action, nil)
5
+ %>
6
+ <aside class="rm-sidebar" id="rm-sidebar">
7
+ <% _all_previews.each do |preview| %>
8
+ <div class="rm-mailer-group">
9
+ <div class="rm-mailer-label"><%= preview.preview_name.gsub("_", " ").titleize %></div>
10
+ <% preview.emails.each do |email| %>
11
+ <%
12
+ path = "#{preview.preview_name}/#{email}"
13
+ href = url_for(controller: "rails/mailers", action: "preview", path: path)
14
+ active = _active_preview == preview && _active_action.to_s == email.to_s
15
+ %>
16
+ <a href="<%= href %>" class="rm-email-link <%= 'active' if active %>">
17
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
18
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
19
+ <polyline points="14 2 14 8 20 8"/>
20
+ </svg>
21
+ <%= email %>
22
+ </a>
23
+ <% end %>
24
+ </div>
25
+ <% end %>
26
+ </aside>