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 +7 -0
- data/README.md +159 -0
- data/app/views/rails/mailers/_scripts.html.erb +344 -0
- data/app/views/rails/mailers/_sidebar.html.erb +26 -0
- data/app/views/rails/mailers/_styles.html.erb +299 -0
- data/app/views/rails/mailers/email.html.erb +257 -0
- data/app/views/rails/mailers/index.html.erb +73 -0
- data/app/views/rails/mailers/mailer.html.erb +49 -0
- data/lib/rails_email/engine.rb +60 -0
- data/lib/rails_email/version.rb +3 -0
- data/lib/rails_email.rb +2 -0
- metadata +48 -0
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>
|