studio-engine 0.41.0 → 0.43.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 +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +55 -1
- data/app/assets/images/emails/logo-horizontal.png +0 -0
- data/app/assets/images/emails/magic-link-background.gif +0 -0
- data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
- data/app/controllers/studio/emails_controller.rb +152 -6
- data/app/mailers/studio/newsletter_mailer.rb +47 -0
- data/app/mailers/user_mailer.rb +55 -1
- data/app/models/studio/email_setting.rb +206 -0
- data/app/services/studio/banner.rb +191 -0
- data/app/services/studio/email_catalog.rb +387 -19
- data/app/services/studio/email_preview_target.rb +195 -0
- data/app/views/layouts/branded_mailer.html.erb +55 -6
- data/app/views/studio/emails/_banner_editor.html.erb +199 -0
- data/app/views/studio/emails/_banner_preview.html.erb +62 -0
- data/app/views/studio/emails/_banner_scale.html.erb +56 -0
- data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
- data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
- data/app/views/studio/emails/_row.html.erb +64 -82
- data/app/views/studio/emails/index.html.erb +34 -8
- data/app/views/studio/emails/orphan.html.erb +45 -0
- data/app/views/studio/emails/show.html.erb +468 -34
- data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
- data/app/views/studio/modals/_image_upload.html.erb +55 -3
- data/app/views/studio/newsletter_mailer/subscribed.html.erb +40 -0
- data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
- data/app/views/user_mailer/magic_link.html.erb +49 -12
- data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
- data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
- data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
- data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -0
- metadata +22 -2
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<%#
|
|
2
|
+
The layered email banner: background image, live text on top.
|
|
3
|
+
|
|
4
|
+
locals: (banner:) — a Studio::Banner
|
|
5
|
+
|
|
6
|
+
The markup is deliberately old-fashioned. Outlook on Windows renders through
|
|
7
|
+
Word and ignores `background-image` on nearly everything, so the picture is
|
|
8
|
+
carried three ways at once:
|
|
9
|
+
|
|
10
|
+
* the `background` ATTRIBUTE on the td, which the widest set of clients honour
|
|
11
|
+
* the CSS `background-image`, for the ones that prefer it
|
|
12
|
+
* a VML `v:rect` / `v:fill` inside a `gte mso 9` conditional, which is the
|
|
13
|
+
only thing that reaches Outlook. Every other client never sees it.
|
|
14
|
+
|
|
15
|
+
`bgcolor` is the floor: if the image is blocked or still loading, the cell is a
|
|
16
|
+
brand colour with legible text on it rather than white-on-white.
|
|
17
|
+
|
|
18
|
+
The scrim is a coloured overlay cell between artwork and type. Background art
|
|
19
|
+
is chosen to look good, not to guarantee contrast, and white text on a pale
|
|
20
|
+
sky cannot be read. Set scrim: 0 on artwork dark enough to carry the type.
|
|
21
|
+
%>
|
|
22
|
+
<%
|
|
23
|
+
banner = local_assigns.fetch(:banner)
|
|
24
|
+
|
|
25
|
+
# PREVIEW HOOKS, off by default.
|
|
26
|
+
#
|
|
27
|
+
# The admin page edits this banner's words and repaints it as you type, which
|
|
28
|
+
# needs handles on the three text nodes. They are opt-in because this partial
|
|
29
|
+
# renders REAL EMAIL: shipping data- attributes into every inbox to serve an
|
|
30
|
+
# admin screen is the kind of thing that is invisible until a mail client
|
|
31
|
+
# chokes on it. With preview off the markup is byte-for-byte what it was, and
|
|
32
|
+
# a test asserts exactly that.
|
|
33
|
+
preview = local_assigns.fetch(:preview, false)
|
|
34
|
+
hook = ->(name) { preview ? %( data-banner-#{name}).html_safe : "" }
|
|
35
|
+
|
|
36
|
+
# TYPE AND RHYTHM SCALE WITH THE BANNER.
|
|
37
|
+
#
|
|
38
|
+
# Everything below is a proportion of the height, not a fixed pixel size.
|
|
39
|
+
# Hardcoding 42px type looked right at 300px tall and OVERFLOWED at 200 —
|
|
40
|
+
# the box shrank, the words did not, and the banner rendered 238px with the
|
|
41
|
+
# header clipped at both edges. Proportions mean the next height change is a
|
|
42
|
+
# one-line edit rather than a re-tune.
|
|
43
|
+
h = banner.height
|
|
44
|
+
|
|
45
|
+
header_size = (h * 0.15).round # 30px at 200, 45 at 300
|
|
46
|
+
header_line = (h * 0.17).round
|
|
47
|
+
subtext_size = (h * 0.075).round # 15px at 200
|
|
48
|
+
subtext_line = (h * 0.10).round
|
|
49
|
+
logo_width = (h * 0.60).round # 120px at 200
|
|
50
|
+
side_padding = (banner.width * 0.05).round
|
|
51
|
+
|
|
52
|
+
# The block is vertically centred, so the gap between sub-text and logo is
|
|
53
|
+
# what pushes the header toward the top edge and the logo toward the bottom.
|
|
54
|
+
#
|
|
55
|
+
# It has to ADAPT to the greeting. A gap that fills the banner for "Welcome
|
|
56
|
+
# Alex!" leaves "Welcome Bartholomew Fitzgerald-Montgomery!" clipped, because
|
|
57
|
+
# a wrapped header is a whole line taller while the box is not. The line
|
|
58
|
+
# estimate is deliberately crude — it only has to answer "one line or more",
|
|
59
|
+
# client fonts differ anyway, and being wrong costs breathing room rather
|
|
60
|
+
# than a broken banner.
|
|
61
|
+
gap_after_header = (h * 0.02).round
|
|
62
|
+
chars_per_line = 21
|
|
63
|
+
header_lines = [(banner.header.to_s.length / chars_per_line.to_f).ceil, 1].max
|
|
64
|
+
gap_before_logo = header_lines > 1 ? (h * 0.18).round : (h * 0.35).round
|
|
65
|
+
%>
|
|
66
|
+
<table role="presentation" width="<%= banner.width %>" cellpadding="0" cellspacing="0" border="0"
|
|
67
|
+
style="width:<%= banner.width %>px;border-collapse:collapse;">
|
|
68
|
+
<tr>
|
|
69
|
+
<td <%= %(background="#{banner.background_url}").html_safe if banner.background_url.present? %>
|
|
70
|
+
bgcolor="<%= Studio.theme_primary %>"
|
|
71
|
+
width="<%= banner.width %>" height="<%= banner.height %>" valign="middle"
|
|
72
|
+
style="width:<%= banner.width %>px;height:<%= banner.height %>px;<%= "background-image:url(#{banner.background_url});background-size:cover;background-position:center;background-repeat:no-repeat;" if banner.background_url.present? %>">
|
|
73
|
+
<% if banner.background_url.present? %>
|
|
74
|
+
<!--[if gte mso 9]>
|
|
75
|
+
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false"
|
|
76
|
+
style="width:<%= banner.width %>px;height:<%= banner.height %>px;">
|
|
77
|
+
<v:fill type="frame" src="<%= banner.background_url %>" color="<%= Studio.theme_primary %>" />
|
|
78
|
+
<v:textbox inset="0,0,0,0">
|
|
79
|
+
<![endif]-->
|
|
80
|
+
<% end %>
|
|
81
|
+
<div>
|
|
82
|
+
<table role="presentation" width="<%= banner.width %>" cellpadding="0" cellspacing="0" border="0"
|
|
83
|
+
style="width:<%= banner.width %>px;">
|
|
84
|
+
<tr>
|
|
85
|
+
<%# VERTICAL PADDING IS ZERO ON PURPOSE.
|
|
86
|
+
A table cell's padding ADDS to its declared height, so
|
|
87
|
+
height:300 plus 26px top and bottom rendered a 352px banner.
|
|
88
|
+
Dropping the height instead shrank the scrim to a band across
|
|
89
|
+
the middle, because the tinted cell then only covered its own
|
|
90
|
+
content. Full height with horizontal-only padding gives both:
|
|
91
|
+
exactly 300px, and a wash over the whole picture. valign does
|
|
92
|
+
the vertical centring that the padding was doing. %>
|
|
93
|
+
<td align="center" valign="middle" height="<%= banner.height %>"<%= hook.call("scrim") %>
|
|
94
|
+
<%# bgcolor is the OUTLOOK path and the CSS below is everywhere
|
|
95
|
+
else. Word ignores rgba(), so without the attribute the wash
|
|
96
|
+
vanishes there and white text sits on bare artwork — the exact
|
|
97
|
+
contrast case the scrim exists for, in the one client that
|
|
98
|
+
cannot be spot-checked. Every other client honours the rgba
|
|
99
|
+
declaration and overrides the flat attribute. %>
|
|
100
|
+
<%= %(bgcolor="#{banner.scrim_solid_hex}").html_safe if banner.scrim_opacity.positive? %>
|
|
101
|
+
style="height:<%= banner.height %>px;padding:0 <%= side_padding %>px;<%= "background-color:rgba(24,16,64,#{banner.scrim_opacity});" if banner.scrim_opacity.positive? %>">
|
|
102
|
+
<% if preview || banner.header.present? %>
|
|
103
|
+
<p<%= hook.call("header") %> style="margin:0 0 <%= gap_after_header %>px;font-family:Montserrat,'Segoe UI',Helvetica,Arial,sans-serif;font-size:<%= header_size %>px;line-height:<%= header_line %>px;font-weight:700;color:#ffffff;">
|
|
104
|
+
<%= banner.header %>
|
|
105
|
+
</p>
|
|
106
|
+
<% end %>
|
|
107
|
+
<% if preview || banner.subtext.present? %>
|
|
108
|
+
<p<%= hook.call("subtext") %> style="margin:0 0 <%= gap_before_logo %>px;font-family:Montserrat,'Segoe UI',Helvetica,Arial,sans-serif;font-size:<%= subtext_size %>px;line-height:<%= subtext_line %>px;color:#efeaff;">
|
|
109
|
+
<%= banner.subtext %>
|
|
110
|
+
</p>
|
|
111
|
+
<% end %>
|
|
112
|
+
<% if banner.logo_url.present? %>
|
|
113
|
+
<%= image_tag banner.logo_url, width: logo_width, alt: banner.logo_alt.to_s,
|
|
114
|
+
data: (preview ? { banner_logo: true } : {}),
|
|
115
|
+
style: "display:block;margin:0 auto;width:#{logo_width}px;height:auto;border:0;" %>
|
|
116
|
+
<% elsif preview %>
|
|
117
|
+
<%# A hidden placeholder so ticking "hide the logo" and unticking it
|
|
118
|
+
again repaints without a round trip. Preview only — an email
|
|
119
|
+
with no logo ships no img tag at all. %>
|
|
120
|
+
<img data-banner-logo width="<%= logo_width %>" alt=""
|
|
121
|
+
src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
|
|
122
|
+
style="display:none;margin:0 auto;width:<%= logo_width %>px;height:auto;border:0;">
|
|
123
|
+
<% end %>
|
|
124
|
+
</td>
|
|
125
|
+
</tr>
|
|
126
|
+
</table>
|
|
127
|
+
</div>
|
|
128
|
+
<% if banner.background_url.present? %>
|
|
129
|
+
<!--[if gte mso 9]>
|
|
130
|
+
</v:textbox>
|
|
131
|
+
</v:rect>
|
|
132
|
+
<![endif]-->
|
|
133
|
+
<% end %>
|
|
134
|
+
</td>
|
|
135
|
+
</tr>
|
|
136
|
+
</table>
|
|
@@ -44,6 +44,16 @@
|
|
|
44
44
|
maxWidth: 256,
|
|
45
45
|
maxHeight: 256,
|
|
46
46
|
transparent: true,
|
|
47
|
+
// GIFS BYPASS THE CROPPER ENTIRELY when allowed. They cannot go through
|
|
48
|
+
// it: confirm() paints the crop onto a canvas and calls toBlob(...,
|
|
49
|
+
// "image/png"), which keeps frame one and throws the animation away —
|
|
50
|
+
// silently, with a perfectly good-looking still as the result. So an
|
|
51
|
+
// allowed GIF is passed through as its ORIGINAL BYTES, uncropped, and the
|
|
52
|
+
// aspect it was authored at is the aspect that ships.
|
|
53
|
+
//
|
|
54
|
+
// Off by default because most upload sites (avatars, square thumbnails)
|
|
55
|
+
// genuinely want the crop, and an un-cropped image there is a bug.
|
|
56
|
+
allowGifs: false,
|
|
47
57
|
autoCropArea: 0.9,
|
|
48
58
|
// dispatch: keep the modal open after confirm so the opener's host can run
|
|
49
59
|
// its own processing -> success flow (it replaces the modal). When false
|
|
@@ -57,6 +67,7 @@
|
|
|
57
67
|
if (props.maxWidth) this.maxWidth = props.maxWidth;
|
|
58
68
|
if (props.maxHeight) this.maxHeight = props.maxHeight;
|
|
59
69
|
if (typeof props.transparent === "boolean") this.transparent = props.transparent;
|
|
70
|
+
if (typeof props.allowGifs === "boolean") this.allowGifs = props.allowGifs;
|
|
60
71
|
if (props.dispatch) this.dispatch = true;
|
|
61
72
|
// Carried through untouched and echoed back on confirm, so the host that
|
|
62
73
|
// OPENED the cropper is the only one that acts on the result.
|
|
@@ -103,6 +114,17 @@
|
|
|
103
114
|
return;
|
|
104
115
|
}
|
|
105
116
|
var self = this;
|
|
117
|
+
|
|
118
|
+
if (this.isAnimatedCandidate(file)) {
|
|
119
|
+
if (!this.allowGifs) {
|
|
120
|
+
this.error = "GIFs aren't accepted here. Use a PNG, JPG or WebP.";
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
this.error = null;
|
|
124
|
+
this.passThrough(file);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
106
128
|
var reader = new FileReader();
|
|
107
129
|
reader.onload = function (e) {
|
|
108
130
|
self.error = null;
|
|
@@ -112,6 +134,25 @@
|
|
|
112
134
|
reader.readAsDataURL(file);
|
|
113
135
|
},
|
|
114
136
|
|
|
137
|
+
// Type, not extension: a file renamed to .gif is still a PNG and belongs in
|
|
138
|
+
// the cropper, and a .GIF from a Windows machine is still a GIF.
|
|
139
|
+
isAnimatedCandidate(file) {
|
|
140
|
+
return (file.type || "").toLowerCase() === "image/gif";
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
// Hand the ORIGINAL file straight to the opener, no canvas in the path.
|
|
144
|
+
// Same event and the same owner token as a cropped confirm, so every host
|
|
145
|
+
// downstream is unchanged — the only difference is what is in the blob.
|
|
146
|
+
passThrough(file) {
|
|
147
|
+
try {
|
|
148
|
+
window.dispatchEvent(new CustomEvent("crop-photo-confirmed",
|
|
149
|
+
{ detail: { blob: file, owner: this.owner || null,
|
|
150
|
+
filename: file.name, uncropped: true } }));
|
|
151
|
+
} catch (_) {}
|
|
152
|
+
if (this.cropper) { this.cropper.destroy(); this.cropper = null; }
|
|
153
|
+
if (!this.dispatch) this.$store[this._storeName].close();
|
|
154
|
+
},
|
|
155
|
+
|
|
115
156
|
onFilePicked(event) {
|
|
116
157
|
var file = event.target.files[0];
|
|
117
158
|
event.target.value = "";
|
|
@@ -228,6 +269,7 @@
|
|
|
228
269
|
if (opts.maxWidth) p.maxWidth = opts.maxWidth;
|
|
229
270
|
if (opts.maxHeight) p.maxHeight = opts.maxHeight;
|
|
230
271
|
if (opts.autoCropArea) p.autoCropArea = opts.autoCropArea;
|
|
272
|
+
if (typeof opts.allowGifs === "boolean") p.allowGifs = opts.allowGifs;
|
|
231
273
|
if (extra) { for (var k in extra) { p[k] = extra[k]; } }
|
|
232
274
|
return p;
|
|
233
275
|
}
|
|
@@ -249,7 +291,12 @@
|
|
|
249
291
|
onCropConfirmed(detail) {
|
|
250
292
|
if (!detail) return;
|
|
251
293
|
if (detail.owner && detail.owner !== ownerId) return;
|
|
252
|
-
|
|
294
|
+
// An uncropped pass-through (a GIF) carries its own name and type. The
|
|
295
|
+
// cropped path does not — it produced a PNG — so it keeps the caller's
|
|
296
|
+
// filename. Sending "banner.png" for GIF bytes would have the server
|
|
297
|
+
// store an animated file under a name and content type that say
|
|
298
|
+
// otherwise, and the inbox would be told it is a PNG.
|
|
299
|
+
this.applyCrop(detail.blob, detail.filename);
|
|
253
300
|
},
|
|
254
301
|
// Native picker: read the chosen image, then hand it to the modal.
|
|
255
302
|
onFileSelected(event) {
|
|
@@ -262,10 +309,15 @@
|
|
|
262
309
|
reader.readAsDataURL(file);
|
|
263
310
|
event.target.value = "";
|
|
264
311
|
},
|
|
265
|
-
applyCrop(blob) {
|
|
312
|
+
applyCrop(blob, filename) {
|
|
266
313
|
if (!blob) return;
|
|
314
|
+
// The blob's OWN type when it has one — a pass-through GIF arrives as the
|
|
315
|
+
// original File and already knows it is image/gif. Only the cropped path
|
|
316
|
+
// needs the png default, because that is what toBlob produced.
|
|
317
|
+
var type = blob.type || "image/png";
|
|
318
|
+
var name = filename || opts.filename || "image.png";
|
|
267
319
|
var dt = new DataTransfer();
|
|
268
|
-
dt.items.add(new File([blob],
|
|
320
|
+
dt.items.add(new File([blob], name, { type: type }));
|
|
269
321
|
this.$refs.fileInput.files = dt.files;
|
|
270
322
|
window.submitFormWithProgress(this.$refs.form, opts);
|
|
271
323
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
<%# Body only — branded_mailer supplies the banner + card. Colours mirror
|
|
2
|
+
user_mailer/magic_link: brand primary for anything actionable, so the two
|
|
3
|
+
emails read as coming from the same place. %>
|
|
4
|
+
<h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">You're on the list</h1>
|
|
5
|
+
|
|
6
|
+
<%# From the catalogue, so /admin/emails owns this copy.
|
|
7
|
+
|
|
8
|
+
sanitize: true strips scripts and event handlers; it leaves a, strong and img
|
|
9
|
+
standing. The author is an admin, so that is a reasonable line — but it is not
|
|
10
|
+
"no HTML", and describing it that way would misstate the guard. %>
|
|
11
|
+
<%# Resolved here as well as in the mailer, for the same reason the sign-in view
|
|
12
|
+
does it: a host may render this view from its own mailer. %>
|
|
13
|
+
<% body = @body.presence || Studio::EmailCatalog.body(:newsletter_subscribed) %>
|
|
14
|
+
<% if body.present? %>
|
|
15
|
+
<%= simple_format body, { style: "margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;" }, sanitize: true %>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
|
|
19
|
+
Nothing else to do. The next one lands in this inbox.
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
<p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
|
|
23
|
+
We're sending to <strong style="color:#2f3a2c;"><%= @email %></strong>.
|
|
24
|
+
</p>
|
|
25
|
+
|
|
26
|
+
<% if @unsubscribe_url.present? %>
|
|
27
|
+
<%# Renders ONLY when the host wired a real URL. A newsletter needs a way out,
|
|
28
|
+
but an unsubscribe link that goes nowhere is worse than none — it spends
|
|
29
|
+
the reader's trust and then fails them. %>
|
|
30
|
+
<p style="margin:24px 0 0;font-size:12px;line-height:1.4;color:#8a948a;text-align:center;">
|
|
31
|
+
Changed your mind?
|
|
32
|
+
<a href="<%= @unsubscribe_url %>" style="color:<%= Studio.theme_primary %>;">Unsubscribe</a>.
|
|
33
|
+
</p>
|
|
34
|
+
<% end %>
|
|
35
|
+
|
|
36
|
+
<p style="margin:28px 0 0;font-size:12px;line-height:1.5;color:#8a948a;text-align:center;">
|
|
37
|
+
Didn't sign up? You can safely ignore this email and we won't send another.
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
<p style="margin:20px 0 0;font-size:13px;color:#6b756a;text-align:center;">— <%= @app_name %></p>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
You're on the <%= @app_name %> list.
|
|
2
|
+
|
|
3
|
+
Thanks for subscribing. We'll send the occasional note about what we're
|
|
4
|
+
building — no more often than it's worth your time.
|
|
5
|
+
|
|
6
|
+
We're sending to <%= @email %>.
|
|
7
|
+
<% if @unsubscribe_url.present? %>
|
|
8
|
+
Changed your mind? Unsubscribe here:
|
|
9
|
+
<%= @unsubscribe_url %>
|
|
10
|
+
<% end %>
|
|
11
|
+
Didn't sign up? You can safely ignore this message and we won't send another.
|
|
12
|
+
|
|
13
|
+
— <%= @app_name %>
|
|
@@ -1,17 +1,50 @@
|
|
|
1
|
-
<%# Body only — branded_mailer supplies the banner + card.
|
|
2
|
-
|
|
1
|
+
<%# Body only — branded_mailer supplies the banner + card.
|
|
2
|
+
|
|
3
|
+
The button and its copy-and-paste fallback share ONE colour — cta_color,
|
|
4
|
+
which the operator can change. They were pinned to different sources, so a
|
|
5
|
+
yellow button left a purple fallback underneath it and the pair the comment
|
|
6
|
+
below promises to keep together came apart.
|
|
7
|
+
|
|
8
|
+
The button is theme PRIMARY by default, not success. Success is the "it worked" green;
|
|
9
|
+
signing in has not happened yet, and the brand colour is what ties the button
|
|
10
|
+
to the banner above it. The copy-and-paste link is the SAME colour on purpose
|
|
11
|
+
— it is the button's fallback, and a green link under a purple button reads
|
|
12
|
+
as a mistake. %>
|
|
3
13
|
<h1 style="margin:0 0 18px;font-size:22px;line-height:1.3;color:#1f2a1c;">Your sign-in link</h1>
|
|
4
14
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
15
|
+
<%# BODY AND BUTTON COME FROM THE CATALOGUE, not from this file. An operator
|
|
16
|
+
edits them on /admin/emails; the strings that used to live here are the
|
|
17
|
+
registry defaults, so an app that never opens that page sends what it always
|
|
18
|
+
sent.
|
|
19
|
+
|
|
20
|
+
simple_format(sanitize: true) turns blank lines into paragraphs and strips
|
|
21
|
+
scripts and event handlers — it does NOT strip all HTML: a, strong and img
|
|
22
|
+
survive. That is acceptable because the author is an admin, but the guard is
|
|
23
|
+
narrower than "no HTML" and the comment should not claim otherwise.
|
|
24
|
+
|
|
25
|
+
THE VIEW RESOLVES THEM ITSELF when the mailer did not. A host that defines
|
|
26
|
+
its own UserMailer — turf-monster does on main; McRitchie Studio does on its
|
|
27
|
+
adoption branch — renders THIS
|
|
28
|
+
view without setting the new ivars, so depending on them alone shipped an
|
|
29
|
+
email with no body and no button to exactly the apps that had customised
|
|
30
|
+
their mail. The mailer's values still win when present, which is what carries
|
|
31
|
+
the recipient's name into the copy. %>
|
|
32
|
+
<%
|
|
33
|
+
body = @body.presence || Studio::EmailCatalog.body(:magic_link)
|
|
34
|
+
cta_text = @cta_text.presence || (Studio::EmailCatalog.cta_enabled?(:magic_link) ? Studio::EmailCatalog.cta_text(:magic_link) : nil)
|
|
35
|
+
cta_color = @cta_color.presence || Studio::EmailCatalog.cta_color(:magic_link)
|
|
36
|
+
%>
|
|
37
|
+
<% if body.present? %>
|
|
38
|
+
<%= simple_format body, { style: "margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;" }, sanitize: true %>
|
|
39
|
+
<% end %>
|
|
9
40
|
|
|
10
|
-
|
|
11
|
-
<
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
</
|
|
41
|
+
<% if cta_text.present? %>
|
|
42
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
|
|
43
|
+
<tr><td align="center" bgcolor="<%= cta_color %>" style="border-radius:10px;">
|
|
44
|
+
<a href="<%= @magic_url %>" style="display:inline-block;padding:16px 40px;font-size:17px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:10px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;"><%= cta_text %> 🪄</a>
|
|
45
|
+
</td></tr>
|
|
46
|
+
</table>
|
|
47
|
+
<% end %>
|
|
15
48
|
|
|
16
49
|
<p style="margin:28px 0 0;font-size:13px;line-height:1.5;color:#6b756a;text-align:center;">
|
|
17
50
|
This link is for <strong style="color:#2f3a2c;"><%= @email %></strong> and expires in <%= Studio.magic_link_ttl.in_minutes.round %> minutes — it can only be used once.
|
|
@@ -19,7 +52,11 @@
|
|
|
19
52
|
|
|
20
53
|
<p style="margin:24px 0 0;font-size:12px;line-height:1.4;color:#8a948a;text-align:center;">
|
|
21
54
|
Button not working? Copy and paste this link:<br>
|
|
22
|
-
|
|
55
|
+
<%# 11px, not the old 9px: this is the link someone falls back to when the
|
|
56
|
+
button fails, so it has to be readable enough to select by hand. It stays
|
|
57
|
+
a step under the 12px label above it — the label explains, the URL is the
|
|
58
|
+
thing you drag across. %>
|
|
59
|
+
<a href="<%= @magic_url %>" style="font-size:11px;line-height:1.5;color:<%= cta_color %>;word-break:break-all;"><%= @magic_url %></a>
|
|
23
60
|
</p>
|
|
24
61
|
|
|
25
62
|
<p style="margin:28px 0 0;font-size:12px;line-height:1.5;color:#8a948a;text-align:center;">
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Per-email, per-APP settings an operator can change from /admin/emails without
|
|
2
|
+
# a deploy — starting with the banner scrim.
|
|
3
|
+
#
|
|
4
|
+
# Separate from ImageCache (which holds the uploaded banner) because this is a
|
|
5
|
+
# NUMBER, not an asset, and separate from the registry (which is code) because
|
|
6
|
+
# the whole point is that an operator can tune it against real artwork and see
|
|
7
|
+
# the result. The registry stays the default; a row here is an override.
|
|
8
|
+
class CreateStudioEmailSettings < ActiveRecord::Migration[7.2]
|
|
9
|
+
def change
|
|
10
|
+
create_table :studio_email_settings do |t|
|
|
11
|
+
# The registry key — "magic_link", "contest_winnings". Not a foreign key:
|
|
12
|
+
# the catalogue is code, and an app may unregister an email without
|
|
13
|
+
# wanting its saved settings destroyed.
|
|
14
|
+
t.string :email_key, null: false
|
|
15
|
+
|
|
16
|
+
# Scrim as a PERCENT (0-100) rather than a float. It is what the operator
|
|
17
|
+
# types and what the page shows; storing the same units the human uses
|
|
18
|
+
# keeps the round-trip lossless and the value obvious in the console.
|
|
19
|
+
t.integer :scrim_percent
|
|
20
|
+
|
|
21
|
+
t.timestamps
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
add_index :studio_email_settings, :email_key, unique: true
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# The banner's WORDS, editable by the operator alongside its tint.
|
|
2
|
+
#
|
|
3
|
+
# Separate migration rather than an edit to CreateStudioEmailSettings: that one
|
|
4
|
+
# has already been installed and run in a host, and rewriting an applied
|
|
5
|
+
# migration is how a schema quietly diverges from the file that claims to
|
|
6
|
+
# describe it.
|
|
7
|
+
#
|
|
8
|
+
# Every column is nullable, and nil means INHERIT — the registry default applies.
|
|
9
|
+
# That distinction is the whole design: "the operator has not set this" and "the
|
|
10
|
+
# operator set this to empty" are different answers, and a NOT NULL default
|
|
11
|
+
# would collapse them.
|
|
12
|
+
class AddCopyToStudioEmailSettings < ActiveRecord::Migration[7.2]
|
|
13
|
+
def change
|
|
14
|
+
change_table :studio_email_settings, bulk: true do |t|
|
|
15
|
+
# Supports a {name} placeholder, which is what keeps the field honest for
|
|
16
|
+
# an email whose greeting is per-recipient.
|
|
17
|
+
t.string :header
|
|
18
|
+
|
|
19
|
+
# Used when no name is known. A magic link may be the first contact we
|
|
20
|
+
# ever have with someone, so "Welcome {name}!" has to have somewhere to
|
|
21
|
+
# fall back to that is not "Welcome !".
|
|
22
|
+
t.string :header_fallback
|
|
23
|
+
|
|
24
|
+
t.string :subtext
|
|
25
|
+
|
|
26
|
+
# Blank inherits the registry logo. hide_logo is the deliberate "no logo"
|
|
27
|
+
# answer — without it, blank would have to mean both inherit and none.
|
|
28
|
+
t.string :logo_url
|
|
29
|
+
t.boolean :hide_logo, null: false, default: false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# The subject line, editable beside the banner's words.
|
|
2
|
+
#
|
|
3
|
+
# Its OWN migration, and that is the point rather than an oversight. The subject
|
|
4
|
+
# column was first written into AddCopyToStudioEmailSettings, which a host had
|
|
5
|
+
# already run — so the column silently never appeared, and the manager offered a
|
|
6
|
+
# field backed by nothing. Editing an applied migration does not re-apply it; it
|
|
7
|
+
# only makes the file disagree with the database it claims to describe.
|
|
8
|
+
class AddSubjectToStudioEmailSettings < ActiveRecord::Migration[7.2]
|
|
9
|
+
def change
|
|
10
|
+
# Nullable, because nil means INHERIT the registry default — the same
|
|
11
|
+
# distinction every other copy column carries. A NOT NULL default would
|
|
12
|
+
# collapse "never set" into "set to empty".
|
|
13
|
+
add_column :studio_email_settings, :subject, :string
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# The email BELOW the banner: its body copy, its call to action, and the footer
|
|
2
|
+
# every email shares.
|
|
3
|
+
#
|
|
4
|
+
# Same rule as the banner's words — nil means INHERIT the registry default, so
|
|
5
|
+
# an app that never opens /admin/emails sends exactly what it sent before.
|
|
6
|
+
#
|
|
7
|
+
# The FOOTER is app-wide rather than per-email, and it lives in this same table
|
|
8
|
+
# under a reserved key (Studio::EmailSetting::FOOTER_KEY). A separate table for
|
|
9
|
+
# two strings would cost a migration, a model and a join to say the same thing;
|
|
10
|
+
# a reserved key keeps one place to look for "what has the operator changed".
|
|
11
|
+
class AddBodyCtaFooterToStudioEmailSettings < ActiveRecord::Migration[7.2]
|
|
12
|
+
def change
|
|
13
|
+
change_table :studio_email_settings, bulk: true do |t|
|
|
14
|
+
# The paragraph(s) under the header. Rendered through
|
|
15
|
+
# simple_format(sanitize: true), which makes blank lines into paragraphs and
|
|
16
|
+
# strips scripts and event handlers — inline tags like a and strong survive.
|
|
17
|
+
# The author is an admin, so that line is deliberate rather than an oversight.
|
|
18
|
+
t.text :body
|
|
19
|
+
|
|
20
|
+
t.string :cta_text
|
|
21
|
+
# Hex, defaulting to the app's primary. Stored rather than derived so an
|
|
22
|
+
# operator can make one email's button stand out.
|
|
23
|
+
t.string :cta_color
|
|
24
|
+
# NULL, not false: three states again. nil inherits the registry's answer,
|
|
25
|
+
# true and false are the operator's.
|
|
26
|
+
t.boolean :cta_enabled
|
|
27
|
+
|
|
28
|
+
# Footer, on the reserved row.
|
|
29
|
+
t.string :discord_url
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -570,6 +570,18 @@ module Studio
|
|
|
570
570
|
constraints: { key: /[a-z0-9_]+/ }
|
|
571
571
|
delete "admin/emails/:key", to: "studio/emails#destroy",
|
|
572
572
|
constraints: { key: /[a-z0-9_]+/ }
|
|
573
|
+
# Operator-tunable per-email settings (the banner scrim today). Separate
|
|
574
|
+
# from #update, which takes an image upload.
|
|
575
|
+
patch "admin/emails/:key/settings", to: "studio/emails#settings",
|
|
576
|
+
as: :admin_email_settings, constraints: { key: /[a-z0-9_]+/ }
|
|
577
|
+
# The banner's words and logo. Its own route so writing a sentence and
|
|
578
|
+
# nudging the tint save independently.
|
|
579
|
+
patch "admin/emails/:key/copy", to: "studio/emails#copy",
|
|
580
|
+
as: :admin_email_copy, constraints: { key: /[a-z0-9_]+/ }
|
|
581
|
+
# The per-email logo. Separate from the banner upload above — different
|
|
582
|
+
# picture, different ImageCache purpose, independently revertible.
|
|
583
|
+
patch "admin/emails/:key/logo", to: "studio/emails#logo",
|
|
584
|
+
as: :admin_email_logo, constraints: { key: /[a-z0-9_]+/ }
|
|
573
585
|
end
|
|
574
586
|
|
|
575
587
|
# DEPRECATED, kept for ONE release. Not a redirect: consumer-ci.yml runs
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.43.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-08-
|
|
11
|
+
date: 2026-08-13 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -169,7 +169,10 @@ files:
|
|
|
169
169
|
- LICENSE
|
|
170
170
|
- README.md
|
|
171
171
|
- app/assets/images/emails/email-change-confirmation.gif
|
|
172
|
+
- app/assets/images/emails/logo-horizontal.png
|
|
173
|
+
- app/assets/images/emails/magic-link-background.gif
|
|
172
174
|
- app/assets/images/emails/magic-link.gif
|
|
175
|
+
- app/assets/images/emails/newsletter-subscribed-background.gif
|
|
173
176
|
- app/assets/images/resend-favicon.png
|
|
174
177
|
- app/assets/images/ses-favicon.png
|
|
175
178
|
- app/assets/javascripts/studio/canvas_confetti.js
|
|
@@ -210,6 +213,7 @@ files:
|
|
|
210
213
|
- app/jobs/error_log_cleanup_job.rb
|
|
211
214
|
- app/jobs/studio/email_delivery_job.rb
|
|
212
215
|
- app/mailers/application_mailer.rb
|
|
216
|
+
- app/mailers/studio/newsletter_mailer.rb
|
|
213
217
|
- app/mailers/user_mailer.rb
|
|
214
218
|
- app/models/concerns/sluggable.rb
|
|
215
219
|
- app/models/concerns/studio/board/rankable.rb
|
|
@@ -219,13 +223,16 @@ files:
|
|
|
219
223
|
- app/models/image_cache.rb
|
|
220
224
|
- app/models/session_context.rb
|
|
221
225
|
- app/models/studio/email_delivery.rb
|
|
226
|
+
- app/models/studio/email_setting.rb
|
|
222
227
|
- app/models/studio/enumeral.rb
|
|
223
228
|
- app/models/studio/link.rb
|
|
224
229
|
- app/models/studio/model_page.rb
|
|
225
230
|
- app/models/theme_setting.rb
|
|
226
231
|
- app/services/google_oauth_validator.rb
|
|
232
|
+
- app/services/studio/banner.rb
|
|
227
233
|
- app/services/studio/email_catalog.rb
|
|
228
234
|
- app/services/studio/email_image.rb
|
|
235
|
+
- app/services/studio/email_preview_target.rb
|
|
229
236
|
- app/views/components/_admin_dropdown.html.erb
|
|
230
237
|
- app/views/components/_avatar.html.erb
|
|
231
238
|
- app/views/components/_avatar_cropper.html.erb
|
|
@@ -277,11 +284,18 @@ files:
|
|
|
277
284
|
- app/views/studio/board/_card_shell.html.erb
|
|
278
285
|
- app/views/studio/board/_column.html.erb
|
|
279
286
|
- app/views/studio/email_images/index.html.erb
|
|
287
|
+
- app/views/studio/emails/_banner_editor.html.erb
|
|
288
|
+
- app/views/studio/emails/_banner_preview.html.erb
|
|
289
|
+
- app/views/studio/emails/_banner_scale.html.erb
|
|
290
|
+
- app/views/studio/emails/_recipient_picker.html.erb
|
|
291
|
+
- app/views/studio/emails/_recipient_repaint.html.erb
|
|
280
292
|
- app/views/studio/emails/_row.html.erb
|
|
281
293
|
- app/views/studio/emails/index.html.erb
|
|
294
|
+
- app/views/studio/emails/orphan.html.erb
|
|
282
295
|
- app/views/studio/emails/show.html.erb
|
|
283
296
|
- app/views/studio/links/confirm.html.erb
|
|
284
297
|
- app/views/studio/local_emails/index.html.erb
|
|
298
|
+
- app/views/studio/mailers/_layered_banner.html.erb
|
|
285
299
|
- app/views/studio/modals/_crop_photo.html.erb
|
|
286
300
|
- app/views/studio/modals/_host.html.erb
|
|
287
301
|
- app/views/studio/modals/_image_upload.html.erb
|
|
@@ -315,6 +329,8 @@ files:
|
|
|
315
329
|
- app/views/studio/modals/templates/_success.html.erb
|
|
316
330
|
- app/views/studio/modals/templates/_wizard.html.erb
|
|
317
331
|
- app/views/studio/models/show.html.erb
|
|
332
|
+
- app/views/studio/newsletter_mailer/subscribed.html.erb
|
|
333
|
+
- app/views/studio/newsletter_mailer/subscribed.text.erb
|
|
318
334
|
- app/views/style/_modal_specimen.html.erb
|
|
319
335
|
- app/views/style/_modals.html.erb
|
|
320
336
|
- app/views/style/_specimen.html.erb
|
|
@@ -336,6 +352,10 @@ files:
|
|
|
336
352
|
- db/migrate/20260620000001_create_studio_links.rb
|
|
337
353
|
- db/migrate/20260620000002_allow_null_image_cache_owner.rb
|
|
338
354
|
- db/migrate/20260623130000_create_studio_enumerals.rb
|
|
355
|
+
- db/migrate/20260812000000_create_studio_email_settings.rb
|
|
356
|
+
- db/migrate/20260812210000_add_copy_to_studio_email_settings.rb
|
|
357
|
+
- db/migrate/20260812220000_add_subject_to_studio_email_settings.rb
|
|
358
|
+
- db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb
|
|
339
359
|
- lib/studio-engine.rb
|
|
340
360
|
- lib/studio.rb
|
|
341
361
|
- lib/studio/cable.rb
|