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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +55 -1
  4. data/app/assets/images/emails/logo-horizontal.png +0 -0
  5. data/app/assets/images/emails/magic-link-background.gif +0 -0
  6. data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
  7. data/app/controllers/studio/emails_controller.rb +152 -6
  8. data/app/mailers/studio/newsletter_mailer.rb +47 -0
  9. data/app/mailers/user_mailer.rb +55 -1
  10. data/app/models/studio/email_setting.rb +206 -0
  11. data/app/services/studio/banner.rb +191 -0
  12. data/app/services/studio/email_catalog.rb +387 -19
  13. data/app/services/studio/email_preview_target.rb +195 -0
  14. data/app/views/layouts/branded_mailer.html.erb +55 -6
  15. data/app/views/studio/emails/_banner_editor.html.erb +199 -0
  16. data/app/views/studio/emails/_banner_preview.html.erb +62 -0
  17. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  18. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  19. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  20. data/app/views/studio/emails/_row.html.erb +64 -82
  21. data/app/views/studio/emails/index.html.erb +34 -8
  22. data/app/views/studio/emails/orphan.html.erb +45 -0
  23. data/app/views/studio/emails/show.html.erb +468 -34
  24. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  25. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  26. data/app/views/studio/newsletter_mailer/subscribed.html.erb +40 -0
  27. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  28. data/app/views/user_mailer/magic_link.html.erb +49 -12
  29. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  30. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  31. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  32. data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
  33. data/lib/studio/version.rb +1 -1
  34. data/lib/studio.rb +12 -0
  35. 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
- this.applyCrop(detail.blob);
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], opts.filename || "image.png", { type: "image/png" }));
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. Uses the theme success
2
- color for the button so it matches each app. %>
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
- <p style="margin:0 0 24px;font-size:16px;line-height:1.6;color:#3f4a3c;">
6
- Tap the button below to sign in to <strong style="color:#1f2a1c;"><%= @app_name %></strong> — no password needed.
7
- If you don't have an account yet, we'll create one for you.
8
- </p>
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
- <table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:0 auto;">
11
- <tr><td align="center" bgcolor="<%= Studio.theme_success %>" style="border-radius:10px;">
12
- <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;">Sign in to <%= @app_name %> 🪄</a>
13
- </td></tr>
14
- </table>
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
- <a href="<%= @magic_url %>" style="font-size:9px;line-height:1.3;color:<%= Studio.theme_success %>;word-break:break-all;"><%= @magic_url %></a>
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.41.0"
2
+ VERSION = "0.43.0"
3
3
  end
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.41.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-12 00:00:00.000000000 Z
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