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
|
@@ -61,6 +61,12 @@ module Studio
|
|
|
61
61
|
module EmailCatalog
|
|
62
62
|
PURPOSE = "email_banner".freeze
|
|
63
63
|
|
|
64
|
+
# The LOGO is a separate purpose, not a variant of the banner. They are
|
|
65
|
+
# different pictures with different rules: a banner is 3:1 artwork that may be
|
|
66
|
+
# an animated GIF, a logo is a small transparent mark. Sharing one purpose
|
|
67
|
+
# would make "revert the banner" and "revert the logo" the same row.
|
|
68
|
+
LOGO_PURPOSE = "email_logo".freeze
|
|
69
|
+
|
|
64
70
|
# What an email is FOR. Transactional = sent in response to something the
|
|
65
71
|
# recipient did; marketing = sent because we decided to. Kept because
|
|
66
72
|
# turf-monster's catalog carried it and the distinction drives real policy
|
|
@@ -74,12 +80,22 @@ module Studio
|
|
|
74
80
|
# type — :transactional or :marketing.
|
|
75
81
|
# preview — callable returning a Mail, or nil.
|
|
76
82
|
Entry = Struct.new(:key, :label, :description, :default_asset, :type, :preview,
|
|
77
|
-
:default_origin, :aspect_ratio,
|
|
83
|
+
:default_origin, :background_origin, :aspect_ratio, :background, :logo, :scrim,
|
|
84
|
+
:header, :header_fallback, :subtext, :subject, :body, :cta_text, :cta_color, :cta_enabled, :supports_cta,
|
|
85
|
+
keyword_init: true) do
|
|
78
86
|
def to_s = key
|
|
79
87
|
def previewable? = preview.respond_to?(:call)
|
|
80
88
|
# nil-safe: an Entry built directly (the STANDARD seed) may carry no type.
|
|
81
89
|
def marketing? = type.to_s == "marketing"
|
|
82
90
|
|
|
91
|
+
# Can this email render a button AT ALL? An email's view has to have
|
|
92
|
+
# somewhere to send the reader — the sign-in link has a token URL, a
|
|
93
|
+
# "you're subscribed" note has nothing. OPT-IN, because an entry that has
|
|
94
|
+
# not said yes cannot be assumed to have a destination, and a Call-to-action
|
|
95
|
+
# card offered for an email whose template ignores it is a dead control:
|
|
96
|
+
# the operator ticks the box, saves, and no button ever appears.
|
|
97
|
+
def supports_cta? = supports_cta == true
|
|
98
|
+
|
|
83
99
|
# WHOSE artwork default_asset names. Recorded at registration rather than
|
|
84
100
|
# inferred later: by the time the page asks, a resolved asset path looks
|
|
85
101
|
# identical whether the file came from the gem or from the host, and
|
|
@@ -88,6 +104,24 @@ module Studio
|
|
|
88
104
|
# passes default_asset for is that host's own.
|
|
89
105
|
def engine_artwork? = default_origin.to_s != "app"
|
|
90
106
|
|
|
107
|
+
# Does THIS APP's artwork layer?
|
|
108
|
+
#
|
|
109
|
+
# True when the app registered a background itself — that is the host
|
|
110
|
+
# saying "layer this email", whatever else it registered. Also true when
|
|
111
|
+
# the app has registered no artwork at all, so an app that touches nothing
|
|
112
|
+
# keeps the engine's layered banner.
|
|
113
|
+
#
|
|
114
|
+
# False only in the case this exists for: the app registered its OWN flat
|
|
115
|
+
# asset and merely INHERITED a background it never asked for. turf-monster
|
|
116
|
+
# is exactly that — its magic_link carries a baked-in .jpg and inherits the
|
|
117
|
+
# engine's island art — and drawing live text over a picture it never sends
|
|
118
|
+
# is the failure this whole feature exists to prevent.
|
|
119
|
+
def layered?
|
|
120
|
+
return true if background_origin.to_s == "app"
|
|
121
|
+
|
|
122
|
+
engine_artwork?
|
|
123
|
+
end
|
|
124
|
+
|
|
91
125
|
# The shape of THIS email's banner — the box the page draws and the ratio
|
|
92
126
|
# the upload cropper enforces. Per-entry because the engine's own artwork
|
|
93
127
|
# is 3:1 while turf-monster's eight banners are 2:1; one global constant
|
|
@@ -103,23 +137,84 @@ module Studio
|
|
|
103
137
|
label: "Magic-link sign-in",
|
|
104
138
|
description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
|
|
105
139
|
default_asset: "emails/magic-link.gif",
|
|
106
|
-
aspect_ratio:
|
|
140
|
+
aspect_ratio: 2.0,
|
|
141
|
+
# Layered artwork: the background animates, the greeting is live HTML on
|
|
142
|
+
# top. default_asset above stays the flat <img> for a mailer that has not
|
|
143
|
+
# adopted the layered banner.
|
|
144
|
+
background: "emails/magic-link-background.gif",
|
|
145
|
+
logo: "emails/logo-horizontal.png",
|
|
146
|
+
# The DEFAULT wording, overridable per app on /admin/emails. {name} is
|
|
147
|
+
# filled from whoever the mailer says the recipient is.
|
|
148
|
+
header: "Welcome {name}!",
|
|
149
|
+
header_fallback: "Your Magic Link",
|
|
150
|
+
subtext: "your sign-in link is below",
|
|
151
|
+
subject: "Your {app} sign-in link",
|
|
152
|
+
body: "Tap the button below to sign in to {app} — no password needed. " \
|
|
153
|
+
"If you don't have an account yet, we'll create one for you.",
|
|
154
|
+
# The token URL is the destination, so this email really does have a button.
|
|
155
|
+
supports_cta: true,
|
|
156
|
+
cta_text: "Sign in to {app}"
|
|
107
157
|
},
|
|
108
158
|
{
|
|
109
|
-
key: "
|
|
110
|
-
label: "
|
|
111
|
-
description: "
|
|
112
|
-
|
|
113
|
-
|
|
159
|
+
key: "newsletter_subscribed",
|
|
160
|
+
label: "Newsletter subscribed",
|
|
161
|
+
description: "Welcomes someone who has just joined the mailing list.",
|
|
162
|
+
aspect_ratio: 2.0,
|
|
163
|
+
# LAYERED-NATIVE: no default_asset. A flat asset is the pre-layered
|
|
164
|
+
# fallback — artwork with the words baked in, for a mailer that only
|
|
165
|
+
# knows how to render an <img>. Studio::NewsletterMailer has known how to
|
|
166
|
+
# layer since the day it was written, so a baked-in copy of the same
|
|
167
|
+
# picture would be a second thing to keep in sync and never be shown.
|
|
168
|
+
background: "emails/newsletter-subscribed-background.gif",
|
|
169
|
+
logo: "emails/logo-horizontal.png",
|
|
170
|
+
header: "Welcome {name}!",
|
|
171
|
+
header_fallback: "You're subscribed!",
|
|
172
|
+
subtext: "you're on the list",
|
|
173
|
+
subject: "You're subscribed to {app}",
|
|
174
|
+
body: "Thanks for subscribing to {app}. We'll send the occasional note " \
|
|
175
|
+
"about what we're building — no more often than it's worth your time.",
|
|
176
|
+
# NO BUTTON, and no card offering one: a new subscriber has nowhere to be
|
|
177
|
+
# sent, so subscribed.html.erb renders none and never will.
|
|
178
|
+
supports_cta: false,
|
|
179
|
+
# The engine ships this email's preview because it can: the mailer takes
|
|
180
|
+
# a bare address, so no host sample data is involved. Every other entry's
|
|
181
|
+
# builder needs records only the host has — this one does not, and an
|
|
182
|
+
# inherited email with no preview is a row on every app's manager that
|
|
183
|
+
# cannot be looked at.
|
|
184
|
+
preview: -> { Studio::NewsletterMailer.subscribed("preview@example.com", name: "Alex") }
|
|
114
185
|
}
|
|
115
186
|
].freeze
|
|
116
187
|
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
188
|
+
# The FALLBACK shape, for an email that states none. 2:1 because that is what
|
|
189
|
+
# turf-monster's eight banners are, and changing it would recrop all of them.
|
|
190
|
+
#
|
|
191
|
+
# The engine's own two entries now declare 2.0 as well, and their backgrounds
|
|
192
|
+
# are 1200x600 — but the ratio stays per-entry, because that is what lets a
|
|
193
|
+
# host register artwork of a different shape without recropping everyone's.
|
|
120
194
|
ASPECT_RATIO = 2.0
|
|
121
195
|
MAX_WIDTH = 1200
|
|
122
196
|
|
|
197
|
+
# NO DEFAULT LOGO, deliberately.
|
|
198
|
+
#
|
|
199
|
+
# It was emails/logo-horizontal.png — the white "McRITCHIE STUDIO" wordmark —
|
|
200
|
+
# and every consumer inherits this layout without defining its own. That put
|
|
201
|
+
# Studio branding into turf-monster's entire player-facing mail set, which
|
|
202
|
+
# today carries none, plus moms-app, mcritchie-industries, acquisition-studio
|
|
203
|
+
# and rolio, with no opt-in and no host-side change.
|
|
204
|
+
#
|
|
205
|
+
# It also contradicted the rule at the top of this file: the url / resolved_url
|
|
206
|
+
# split exists so the engine cannot swap a host's artwork for the engine's in
|
|
207
|
+
# live email. A footer logo is the same swap by another route.
|
|
208
|
+
#
|
|
209
|
+
# Each app opts in on /admin/emails by pasting its own mark.
|
|
210
|
+
|
|
211
|
+
# The footer band. Dark on purpose: it closes the white card, and a light
|
|
212
|
+
# sign-off floating under body copy reads as part of the message rather than
|
|
213
|
+
# the end of it. Hard-coded rather than derived from the theme because a
|
|
214
|
+
# host's primary can be light, and white-on-light is unreadable — the one
|
|
215
|
+
# thing this band must never be.
|
|
216
|
+
FOOTER_BACKGROUND = "#1A1535".freeze
|
|
217
|
+
|
|
123
218
|
module_function
|
|
124
219
|
|
|
125
220
|
# --- Registry ----------------------------------------------------------
|
|
@@ -130,7 +225,9 @@ module Studio
|
|
|
130
225
|
# existing value — that is what lets a host relabel an inherited email, or
|
|
131
226
|
# attach a preview builder to it, without restating its artwork.
|
|
132
227
|
def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil,
|
|
133
|
-
aspect_ratio: nil
|
|
228
|
+
aspect_ratio: nil, background: nil, logo: nil, scrim: nil,
|
|
229
|
+
header: nil, header_fallback: nil, subtext: nil, subject: nil,
|
|
230
|
+
body: nil, cta_text: nil, cta_color: nil, cta_enabled: nil, supports_cta: nil)
|
|
134
231
|
key = key.to_s
|
|
135
232
|
existing = registry[key]
|
|
136
233
|
registry[key] = Entry.new(
|
|
@@ -145,7 +242,24 @@ module Studio
|
|
|
145
242
|
# had, so a host relabelling an inherited email does not accidentally
|
|
146
243
|
# claim the engine's picture as its own.
|
|
147
244
|
default_origin: default_asset.nil? ? (existing&.default_origin || :engine) : :app,
|
|
148
|
-
|
|
245
|
+
# WHOSE background this is, recorded the same way and for the same
|
|
246
|
+
# reason: by the time anyone asks, an inherited background and a
|
|
247
|
+
# host-registered one are indistinguishable, and guessing is what this
|
|
248
|
+
# records to avoid. A host passing background: is ASKING to layer.
|
|
249
|
+
background_origin: background.nil? ? (existing&.background_origin || :engine) : :app,
|
|
250
|
+
aspect_ratio: aspect_ratio || existing&.aspect_ratio,
|
|
251
|
+
background: background.nil? ? existing&.background : background.presence,
|
|
252
|
+
logo: logo.nil? ? existing&.logo : logo.presence,
|
|
253
|
+
scrim: scrim.nil? ? existing&.scrim : scrim,
|
|
254
|
+
header: header.nil? ? existing&.header : header.presence,
|
|
255
|
+
header_fallback: header_fallback.nil? ? existing&.header_fallback : header_fallback.presence,
|
|
256
|
+
subtext: subtext.nil? ? existing&.subtext : subtext.presence,
|
|
257
|
+
subject: subject.nil? ? existing&.subject : subject.presence,
|
|
258
|
+
body: body.nil? ? existing&.body : body.presence,
|
|
259
|
+
cta_text: cta_text.nil? ? existing&.cta_text : cta_text.presence,
|
|
260
|
+
cta_color: cta_color.nil? ? existing&.cta_color : cta_color.presence,
|
|
261
|
+
cta_enabled: cta_enabled.nil? ? existing&.cta_enabled : cta_enabled,
|
|
262
|
+
supports_cta: supports_cta.nil? ? existing&.supports_cta : supports_cta
|
|
149
263
|
)
|
|
150
264
|
key
|
|
151
265
|
end
|
|
@@ -202,7 +316,9 @@ module Studio
|
|
|
202
316
|
@registry ||= STANDARD.each_with_object({}) do |attrs, out|
|
|
203
317
|
out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]),
|
|
204
318
|
preview: attrs[:preview], default_origin: :engine,
|
|
205
|
-
aspect_ratio: attrs[:aspect_ratio]
|
|
319
|
+
aspect_ratio: attrs[:aspect_ratio],
|
|
320
|
+
background: attrs[:background], logo: attrs[:logo],
|
|
321
|
+
scrim: attrs[:scrim])
|
|
206
322
|
end
|
|
207
323
|
end
|
|
208
324
|
|
|
@@ -239,6 +355,172 @@ module Studio
|
|
|
239
355
|
# This email's banner shape, falling back to the shared default.
|
|
240
356
|
def ratio(key) = entry(key)&.ratio || ASPECT_RATIO
|
|
241
357
|
|
|
358
|
+
# --- layered banner artwork ---------------------------------------------
|
|
359
|
+
#
|
|
360
|
+
# Absolute URLs, because a mail client fetches these from an inbox and a
|
|
361
|
+
# root-relative path resolves against nothing there.
|
|
362
|
+
|
|
363
|
+
# Saved by the operator > registered by the app > engine default.
|
|
364
|
+
def scrim(key)
|
|
365
|
+
Studio::EmailSetting.scrim_for(key) || entry(key)&.scrim
|
|
366
|
+
rescue StandardError
|
|
367
|
+
entry(key)&.scrim
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# --- the banner's words -------------------------------------------------
|
|
371
|
+
#
|
|
372
|
+
# Same order as the tint, for the same reason: the operator is the one
|
|
373
|
+
# looking at the artwork. Each falls back to the registry, then to a
|
|
374
|
+
# sensible default, so an email that nobody has configured still reads.
|
|
375
|
+
|
|
376
|
+
# The header TEMPLATE — it may contain {name}. Interpolation happens in
|
|
377
|
+
# Studio::Banner, which is the only place that knows the recipient.
|
|
378
|
+
def header_template(key)
|
|
379
|
+
saved(key, :header) || entry(key)&.header || entry(key)&.label
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# What the header says when no name is known. A magic link is often the
|
|
383
|
+
# first contact we have with someone, so "Welcome {name}!" must have
|
|
384
|
+
# somewhere to land that is not "Welcome !".
|
|
385
|
+
def header_fallback(key)
|
|
386
|
+
saved(key, :header_fallback) || entry(key)&.header_fallback || entry(key)&.label
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def subtext(key)
|
|
390
|
+
saved(key, :subtext) || entry(key)&.subtext
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# --- the email below the banner ------------------------------------------
|
|
394
|
+
#
|
|
395
|
+
# Same resolution as the banner's words: operator > registry > default. A
|
|
396
|
+
# mailer reads these instead of hard-coding copy, which is what makes the
|
|
397
|
+
# cards on /admin/emails real rather than decorative.
|
|
398
|
+
|
|
399
|
+
def body(key, name: nil)
|
|
400
|
+
template = saved(key, :body) || entry(key)&.body
|
|
401
|
+
return nil if template.blank?
|
|
402
|
+
|
|
403
|
+
Studio::Banner.interpolate(template, name).presence
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def cta_text(key, name: nil)
|
|
407
|
+
template = saved(key, :cta_text) || entry(key)&.cta_text
|
|
408
|
+
return nil if template.blank?
|
|
409
|
+
|
|
410
|
+
Studio::Banner.interpolate(template, name).presence
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# The app's primary unless this email says otherwise — a button that matches
|
|
414
|
+
# the banner above it by default, and can be made to stand out per email.
|
|
415
|
+
def cta_color(key)
|
|
416
|
+
saved(key, :cta_color) || entry(key)&.cta_color || Studio.theme_primary
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# Shown unless someone said no. Defaults to TRUE for an email that has CTA
|
|
420
|
+
# text, because the button is the point of a transactional email; an email
|
|
421
|
+
# with no text has nothing to render either way.
|
|
422
|
+
def cta_enabled?(key)
|
|
423
|
+
# An email whose template cannot render a button is never "enabled", no
|
|
424
|
+
# matter what is stored — otherwise a value saved before the capability was
|
|
425
|
+
# declared keeps claiming a button that cannot appear.
|
|
426
|
+
return false unless entry(key)&.supports_cta?
|
|
427
|
+
|
|
428
|
+
operator = begin
|
|
429
|
+
Studio::EmailSetting.cta_enabled_for(key)
|
|
430
|
+
rescue StandardError
|
|
431
|
+
nil
|
|
432
|
+
end
|
|
433
|
+
return operator unless operator.nil?
|
|
434
|
+
|
|
435
|
+
registered = entry(key)&.cta_enabled
|
|
436
|
+
return registered unless registered.nil?
|
|
437
|
+
|
|
438
|
+
true
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# The shared footer — the same on every email this app sends.
|
|
442
|
+
#
|
|
443
|
+
# The operator's footer, or nothing. An app that has not set one renders no
|
|
444
|
+
# band at all — the engine ships no branding of its own into a host's email.
|
|
445
|
+
def footer
|
|
446
|
+
(Studio::EmailSetting.footer || {}).compact
|
|
447
|
+
rescue StandardError
|
|
448
|
+
{}
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# The subject line, resolved the same way and supporting the same {name}
|
|
452
|
+
# placeholder. A mailer calls this instead of hard-coding a string, which is
|
|
453
|
+
# what makes the field on /admin/emails real rather than decorative.
|
|
454
|
+
def subject_for(key, name: nil)
|
|
455
|
+
template = saved(key, :subject) || entry(key)&.subject
|
|
456
|
+
return nil if template.blank?
|
|
457
|
+
|
|
458
|
+
Studio::Banner.interpolate(template, name).presence
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# nil when the operator has hidden the logo — distinct from "none saved",
|
|
462
|
+
# which inherits the registry's.
|
|
463
|
+
# Hidden > uploaded here > a URL the operator typed > the registry's.
|
|
464
|
+
# "Hidden" comes first because it is the one answer the others cannot express.
|
|
465
|
+
def resolved_logo_url(key)
|
|
466
|
+
return nil if Studio::EmailSetting.hide_logo?(key)
|
|
467
|
+
|
|
468
|
+
uploaded_logo_url(key) || saved(key, :logo_url) || logo_url(key)
|
|
469
|
+
rescue StandardError
|
|
470
|
+
logo_url(key)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
# An operator-saved field, or nil. Rescues because these are read on a
|
|
474
|
+
# delivery path: a settings table that is missing, locked, or mid-migration
|
|
475
|
+
# must degrade to the registry default rather than fail the send.
|
|
476
|
+
def saved(key, field)
|
|
477
|
+
Studio::EmailSetting.copy_for(key, field)
|
|
478
|
+
rescue StandardError
|
|
479
|
+
nil
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
def scrim_percent(key)
|
|
483
|
+
value = scrim(key) || Studio::Banner::DEFAULT_SCRIM
|
|
484
|
+
(value.to_f * 100).round
|
|
485
|
+
end
|
|
486
|
+
|
|
487
|
+
# THE APP'S OWN UPLOAD WINS, then the registered artwork. Same two layers as
|
|
488
|
+
# resolved_url, and for the same reason: uploading on /admin/emails is how an
|
|
489
|
+
# operator says "this picture is ours now".
|
|
490
|
+
#
|
|
491
|
+
# Reading only the registry made the Upload button a control that lies on a
|
|
492
|
+
# LAYERED email — the upload landed, the page showed it, the provenance badge
|
|
493
|
+
# flipped to "Uploaded here", and the email kept sending the gem's artwork
|
|
494
|
+
# because the layered banner never looked at the row.
|
|
495
|
+
#
|
|
496
|
+
# NIL UNLESS THIS EMAIL LAYERS. A host registering its own flat
|
|
497
|
+
# default_asset sends that picture, and the background it merely INHERITED is
|
|
498
|
+
# the engine's — nothing sends it. But a host that registers a background of
|
|
499
|
+
# its OWN is asking to layer, and layered? is what tells the two apart.
|
|
500
|
+
#
|
|
501
|
+
# This is the ONE place that decision is made. Every reader asks this method
|
|
502
|
+
# rather than re-deriving it: the list row used to carry its own copy of the
|
|
503
|
+
# guard, and the copy went stale the moment the guard moved here.
|
|
504
|
+
def background_url(key)
|
|
505
|
+
return nil unless entry(key)&.layered?
|
|
506
|
+
|
|
507
|
+
url(key) || absolute_asset_url(entry(key)&.background)
|
|
508
|
+
end
|
|
509
|
+
def logo_url(key) = absolute_asset_url(entry(key)&.logo)
|
|
510
|
+
|
|
511
|
+
def absolute_asset_url(asset)
|
|
512
|
+
return nil if asset.blank?
|
|
513
|
+
|
|
514
|
+
path = ActionController::Base.helpers.asset_path(asset)
|
|
515
|
+
return nil if path.blank?
|
|
516
|
+
return path if path.start_with?("http")
|
|
517
|
+
|
|
518
|
+
host = mailer_asset_host
|
|
519
|
+
host ? "#{host}#{path}" : path
|
|
520
|
+
rescue StandardError
|
|
521
|
+
nil
|
|
522
|
+
end
|
|
523
|
+
|
|
242
524
|
def app_owned?(key) = source(key) == :app
|
|
243
525
|
|
|
244
526
|
# --- Preview -----------------------------------------------------------
|
|
@@ -348,7 +630,40 @@ module Studio
|
|
|
348
630
|
# whatever host and port this app is being viewed on (an absolute mailer
|
|
349
631
|
# asset_host is set for the inbox, not for a browser on localhost:3042).
|
|
350
632
|
def preview_url(key)
|
|
351
|
-
url(key) ||
|
|
633
|
+
url(key) || preview_asset_path(key)
|
|
634
|
+
end
|
|
635
|
+
|
|
636
|
+
# What the manager DRAWS, which is a different question from what the flat
|
|
637
|
+
# <img> fallback sends — so it resolves in the opposite order.
|
|
638
|
+
#
|
|
639
|
+
# LAYERED FIRST. magic_link ships both: `emails/magic-link.gif`, the old
|
|
640
|
+
# banner with "Your Magic Link" baked into the picture, and
|
|
641
|
+
# `emails/magic-link-background.gif`, the artwork the layered banner draws
|
|
642
|
+
# live text on top of. A mailer that has adopted layering sends the SECOND
|
|
643
|
+
# one — so previewing the first showed the operator a picture no inbox
|
|
644
|
+
# receives, and did it convincingly, because baked-in words look like a real
|
|
645
|
+
# banner. Same failure as the "No image" badge, one door further along: the
|
|
646
|
+
# page answering from the field it happened to read instead of from what
|
|
647
|
+
# ships.
|
|
648
|
+
#
|
|
649
|
+
# The flat asset stays the fallback, for a host still on the engine's own
|
|
650
|
+
# unlayered UserMailer — there, the baked-text banner IS what arrives.
|
|
651
|
+
#
|
|
652
|
+
# Unless this email does not LAYER, in which case it previews exactly what
|
|
653
|
+
# the flat resolution sends — same method, so the two cannot disagree.
|
|
654
|
+
#
|
|
655
|
+
# THE SAME QUESTION background_url ASKS, so it must ask it the same way.
|
|
656
|
+
# This guard read engine_artwork? while background_url read layered?, and the
|
|
657
|
+
# two answer differently for a host that registers its own background: the
|
|
658
|
+
# mailer sent the layered banner while /admin/email_images and the detail
|
|
659
|
+
# page's "Artwork" frame both drew the flat asset. That frame is where
|
|
660
|
+
# "Modify image" lives, and an upload writes the row background_url reads
|
|
661
|
+
# FIRST — so the operator was shown one picture and told it was the one the
|
|
662
|
+
# button would replace.
|
|
663
|
+
def preview_asset_path(key)
|
|
664
|
+
return default_asset_path(key) unless entry(key)&.layered?
|
|
665
|
+
|
|
666
|
+
asset_path(entry(key)&.background.presence || entry(key)&.default_asset)
|
|
352
667
|
end
|
|
353
668
|
|
|
354
669
|
# The ImageCache row holding this app's override, or nil (nothing uploaded /
|
|
@@ -359,14 +674,60 @@ module Studio
|
|
|
359
674
|
::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
|
|
360
675
|
end
|
|
361
676
|
|
|
362
|
-
|
|
363
|
-
|
|
677
|
+
def logo_record(key)
|
|
678
|
+
return nil unless table_ready?
|
|
679
|
+
|
|
680
|
+
::ImageCache.find_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
|
|
681
|
+
end
|
|
682
|
+
|
|
683
|
+
# An uploaded logo for this email, or nil to inherit.
|
|
684
|
+
def uploaded_logo_url(key)
|
|
685
|
+
logo_record(key)&.url
|
|
686
|
+
rescue StandardError
|
|
687
|
+
nil
|
|
688
|
+
end
|
|
689
|
+
|
|
690
|
+
def store_logo(key, io:, content_type: nil)
|
|
691
|
+
s3_key = "email_logos/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
|
|
692
|
+
Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
|
|
693
|
+
cache_control: "public, max-age=300")
|
|
694
|
+
record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
|
|
695
|
+
previous = record.s3_key
|
|
696
|
+
record.update!(s3_key: s3_key)
|
|
697
|
+
delete_object(previous) if previous.present? && previous != s3_key
|
|
698
|
+
record
|
|
699
|
+
rescue StandardError
|
|
700
|
+
delete_object(s3_key)
|
|
701
|
+
raise
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
def revert_logo(key)
|
|
705
|
+
row = logo_record(key)
|
|
706
|
+
return false if row.nil?
|
|
707
|
+
|
|
708
|
+
previous = row.s3_key
|
|
709
|
+
row.destroy!
|
|
710
|
+
delete_object(previous) if previous.present?
|
|
711
|
+
true
|
|
712
|
+
end
|
|
713
|
+
|
|
714
|
+
# Root-relative path to the FLAT artwork — what the <img> fallback sends.
|
|
715
|
+
# Flat first, then the layered background as a last resort so a
|
|
716
|
+
# layered-native email (newsletter_subscribed registers no flat asset,
|
|
717
|
+
# because it never renders one) still has something rather than nothing.
|
|
718
|
+
# See preview_asset_path above for why the manager resolves the other way.
|
|
364
719
|
def default_asset_path(key)
|
|
365
|
-
|
|
720
|
+
asset_path(entry(key)&.default_asset.presence || entry(key)&.background)
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
# Shared tail of both resolutions: a logical asset name to a root-relative
|
|
724
|
+
# path, or nil when there is no asset or the host's pipeline cannot resolve
|
|
725
|
+
# it. Rescues broadly because a missing asset must degrade to "no image",
|
|
726
|
+
# never take the manager down.
|
|
727
|
+
def asset_path(asset)
|
|
366
728
|
return nil if asset.nil? || asset.empty?
|
|
367
729
|
|
|
368
|
-
|
|
369
|
-
path.presence
|
|
730
|
+
ActionController::Base.helpers.asset_path(asset).presence
|
|
370
731
|
rescue StandardError
|
|
371
732
|
nil
|
|
372
733
|
end
|
|
@@ -477,11 +838,18 @@ module Studio
|
|
|
477
838
|
|
|
478
839
|
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
|
|
479
840
|
|
|
841
|
+
# GIF is listed because animated banners are uploaded whole — they bypass the
|
|
842
|
+
# cropper, which would flatten them to a single PNG frame. Without this branch
|
|
843
|
+
# a GIF was stored under a ".png" key: the object's Content-Type was still
|
|
844
|
+
# image/gif so it played, but the URL said otherwise, and anything that trusts
|
|
845
|
+
# an extension (a CDN, a proxy, a person reading the bucket) was told the
|
|
846
|
+
# wrong thing.
|
|
480
847
|
def ext_for(content_type)
|
|
481
848
|
case content_type.to_s
|
|
482
849
|
when %r{png} then ".png"
|
|
483
850
|
when %r{jpe?g} then ".jpg"
|
|
484
851
|
when %r{webp} then ".webp"
|
|
852
|
+
when %r{gif} then ".gif"
|
|
485
853
|
else ".png"
|
|
486
854
|
end
|
|
487
855
|
end
|