studio-engine 0.40.0 → 0.42.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 +116 -0
  3. data/Gemfile +6 -0
  4. data/README.md +55 -1
  5. data/app/assets/images/emails/logo-horizontal.png +0 -0
  6. data/app/assets/images/emails/magic-link-background.gif +0 -0
  7. data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
  8. data/app/controllers/studio/emails_controller.rb +136 -6
  9. data/app/mailers/studio/newsletter_mailer.rb +43 -0
  10. data/app/mailers/user_mailer.rb +48 -1
  11. data/app/models/studio/email_setting.rb +131 -0
  12. data/app/services/studio/banner.rb +186 -0
  13. data/app/services/studio/email_catalog.rb +248 -19
  14. data/app/services/studio/email_preview_target.rb +195 -0
  15. data/app/views/layouts/branded_mailer.html.erb +23 -6
  16. data/app/views/studio/emails/_banner_editor.html.erb +192 -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 +68 -4
  21. data/app/views/studio/emails/index.html.erb +30 -4
  22. data/app/views/studio/emails/orphan.html.erb +45 -0
  23. data/app/views/studio/emails/show.html.erb +364 -31
  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 +33 -0
  27. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  28. data/app/views/user_mailer/magic_link.html.erb +13 -4
  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/lib/studio/version.rb +1 -1
  33. data/lib/studio.rb +12 -0
  34. data/studio-engine.gemspec +5 -1
  35. metadata +20 -2
@@ -0,0 +1,186 @@
1
+ module Studio
2
+ # A LAYERED email banner: a background image with the header, sub-text and
3
+ # logo sitting on top as live HTML, rather than composed into the picture.
4
+ #
5
+ # ## Why layered rather than composited
6
+ #
7
+ # The alternative is drawing the text into the image server-side. That gives
8
+ # pixel-exact brand typography in every client, but it cannot do the one thing
9
+ # this design needs: an ANIMATED background WITH per-recipient text. Composing
10
+ # "Welcome Mason!" into sixty frames means a multi-megabyte GIF generated per
11
+ # recipient, per send.
12
+ #
13
+ # Layering separates them. The background animates, the greeting is live, and
14
+ # nothing is generated at send time.
15
+ #
16
+ # ## What it costs, stated plainly
17
+ #
18
+ # Gmail and Outlook strip webfonts, so the heading falls back to a system face
19
+ # rather than the brand font. In exchange the text survives blocked images
20
+ # (which Outlook desktop does by default), stays selectable and translatable,
21
+ # and no asset is produced per send.
22
+ #
23
+ # ## Why the markup looks like 1999
24
+ #
25
+ # Outlook on Windows renders through Word, which ignores `background-image` on
26
+ # nearly everything. The `<td background>` attribute plus a VML `v:rect` /
27
+ # `v:fill` block — the long-established "bulletproof background" pattern — is
28
+ # what makes a background image work there at all. The conditional comment is
29
+ # invisible to every other client.
30
+ # A plain class, not a Struct-with-block: constants declared inside a
31
+ # `Struct.new do ... end` attach to the ENCLOSING module, so DEFAULT_SCRIM
32
+ # would have been Studio::DEFAULT_SCRIM and Banner::DEFAULT_SCRIM would not
33
+ # exist. Named constants are part of this object's API, so they live on it.
34
+ class Banner
35
+ ATTRIBUTES = %i[background_url header subtext logo_url logo_alt scrim width height].freeze
36
+ attr_reader(*ATTRIBUTES)
37
+
38
+ def initialize(**attrs)
39
+ unknown = attrs.keys - ATTRIBUTES
40
+ raise ArgumentError, "unknown banner attribute: #{unknown.join(", ")}" if unknown.any?
41
+
42
+ ATTRIBUTES.each { |name| instance_variable_set(:"@#{name}", attrs[name]) }
43
+ end
44
+
45
+ # The email card is 600px wide; the banner fills it.
46
+ DEFAULT_WIDTH = 600
47
+ DEFAULT_HEIGHT = 200
48
+
49
+ # A wash between the artwork and the text. Not decoration: background art is
50
+ # chosen for looks, not contrast, and white text over a pale sky is
51
+ # unreadable. 0 disables it for artwork already dark enough to carry type.
52
+ #
53
+ # Raised from 0.34 after seeing it in a real inbox — bright artwork left the
54
+ # sub-text working harder than it should. Rendered at 0.34 / 0.45 / 0.55 and
55
+ # chosen by eye, because "legible" is a judgement about a picture, not a
56
+ # number a test can settle.
57
+ DEFAULT_SCRIM = 0.40
58
+
59
+ def width = (@width || DEFAULT_WIDTH).to_i
60
+ def height = (@height || DEFAULT_HEIGHT).to_i
61
+
62
+ # The scrim as a SOLID hex, for Outlook.
63
+ #
64
+ # Word's rendering engine ignores rgba(), so the wash simply does not exist
65
+ # there — white text over bare artwork, which is the exact contrast case the
66
+ # scrim was added to solve, in the one client nobody can spot-check. VML
67
+ # cannot layer a translucent fill over an image fill either, so the honest
68
+ # approximation is a solid colour: the scrim tint blended toward the artwork's
69
+ # own darkness by the same fraction. It is not the same picture as everywhere
70
+ # else, and it is legible, which is the point.
71
+ SCRIM_RGB = [24, 16, 64].freeze
72
+
73
+ def scrim_solid_hex
74
+ fraction = scrim_opacity
75
+ # Blend the tint toward mid-grey rather than to black: at low opacities a
76
+ # blend toward black reads far darker in Outlook than the rgba() wash does
77
+ # elsewhere, which trades one wrong picture for another.
78
+ blended = SCRIM_RGB.map { |channel| ((channel * fraction) + (128 * (1 - fraction))).round.clamp(0, 255) }
79
+ format("#%02X%02X%02X", *blended)
80
+ end
81
+
82
+ def scrim_opacity
83
+ value = @scrim
84
+ return DEFAULT_SCRIM if value.nil?
85
+
86
+ value.to_f.clamp(0.0, 1.0)
87
+ end
88
+
89
+ # A banner with nothing to show is not a banner. The layout falls back to
90
+ # the plain <img> path (or to no banner at all).
91
+ def renderable? = background_url.present? || header.present?
92
+
93
+ # Everything the layout needs for one email, or nil.
94
+ #
95
+ # Reads the catalogue for the artwork so an app inherits the shared
96
+ # background and logo without repeating them, and lets a caller override any
97
+ # piece per send — which is the whole point of the header being dynamic.
98
+ # `name` is the DYNAMIC part and the only thing a mailer should normally
99
+ # pass. A mailer that hands over a finished header instead takes the wording
100
+ # away from the operator: the /admin/emails field would still accept an edit
101
+ # and the email would still ignore it — a control that lies about what it
102
+ # does. So the mailer supplies who the person is, and the operator supplies
103
+ # what the banner says about them.
104
+ def self.for(key, name: nil, header: nil, subtext: nil, background_url: nil,
105
+ logo_url: nil, scrim: nil, logo_alt: nil)
106
+ banner = new(
107
+ background_url: background_url || Studio::EmailCatalog.background_url(key),
108
+ logo_url: logo_url || safe_image_url(Studio::EmailCatalog.resolved_logo_url(key)),
109
+ logo_alt: logo_alt || Studio.app_name,
110
+ header: header || header_for(key, name),
111
+ subtext: subtext || Studio::EmailCatalog.subtext(key),
112
+ # Resolution order: an explicit argument (a caller who knows better),
113
+ # then what the OPERATOR saved on /admin/emails, then the registry, then
114
+ # the default. The operator sits above the registry on purpose — they
115
+ # are the one looking at the artwork.
116
+ scrim: scrim || Studio::EmailCatalog.scrim(key)
117
+ )
118
+ # A LAYERED banner needs its picture, and renderable? deliberately accepts
119
+ # a header alone — right for a caller building a Banner directly, wrong
120
+ # here. A nil background means the catalogue said this app sends the email
121
+ # flat, and a text-only card is not what that inbox gets.
122
+ banner.background_url.present? ? banner : nil
123
+ end
124
+
125
+ # The placeholder an operator types into the header field. Braces rather
126
+ # than Ruby's %{name}: an operator-editable string is passed to no formatter
127
+ # here, and a stray "%" in "50% off" would raise inside format() where a
128
+ # stray brace is simply left alone.
129
+ # An operator types the logo URL into an admin form, and it is rendered into
130
+ # an <img src>. Admin-only and low risk, but "javascript:" and "data:" in a
131
+ # src are cheap to refuse and there is no reason to carry them: a logo is
132
+ # fetched over http(s) or served from this app's own asset path.
133
+ def self.safe_image_url(url)
134
+ value = url.to_s.strip
135
+ return nil if value.empty?
136
+ return value if value.start_with?("/")
137
+
138
+ value.match?(%r{\Ahttps?://}i) ? value : nil
139
+ end
140
+
141
+ NAME_PLACEHOLDER = "{name}".freeze
142
+
143
+ # The app's own name. Present because the DEFAULTS need it: "Your sign-in
144
+ # link" reads as though it could be from anyone, and a registry constant
145
+ # cannot interpolate Studio.app_name at load time. An operator gets it for
146
+ # free in any field.
147
+ APP_PLACEHOLDER = "{app}".freeze
148
+
149
+ # FIRST name only. The banner is one line of large type in a 600px box, and
150
+ # "Welcome Bartholomew Fitzgerald-Montgomery!" wraps out of it.
151
+ # Shared with the SUBJECT, which takes the same placeholder. One
152
+ # implementation, so "{name}" cannot mean two things on one email.
153
+ def self.interpolate(template, name)
154
+ text = template.to_s.gsub(APP_PLACEHOLDER, Studio.app_name.to_s)
155
+ first = name.to_s.strip.split.first
156
+ return text.gsub(NAME_PLACEHOLDER, first) if first.present?
157
+
158
+ # NO NAME, AND NO RAW PLACEHOLDER EITHER. The header carries a whole second
159
+ # field for this case; a subject line does not, and "Sign in to Studio,
160
+ # {name}" reaching an inbox is the most visible way this feature fails. The
161
+ # token goes, and the punctuation it hung off goes with it — the result is
162
+ # "Sign in to Studio", not "Sign in to Studio, ".
163
+ # Two passes, because the punctuation can sit on either side: "Sign in,
164
+ # {name}" and "{name}, your link is here" both have to come out clean, and
165
+ # "Hi {name} welcome" must not become "Hiwelcome".
166
+ text.gsub(/[,;:\u2014-]?\s*#{Regexp.escape(NAME_PLACEHOLDER)}/, "")
167
+ .sub(/\A\s*[,;:\u2014-]\s*/, "")
168
+ .squeeze(" ").strip
169
+ end
170
+
171
+ def self.header_for(key, name)
172
+ template = Studio::EmailCatalog.header_template(key).to_s
173
+ first = name.to_s.strip.split.first
174
+
175
+ return interpolate(template, name) if first.present?
176
+ # No name: a template that asks for one cannot be rendered honestly, so
177
+ # the fallback answers instead. A template with no name placeholder is
178
+ # already name-free and stands as written — still interpolated, because
179
+ # {app} does not depend on the recipient.
180
+ fallback = Studio::EmailCatalog.header_fallback(key) if template.include?(NAME_PLACEHOLDER)
181
+
182
+ interpolate(fallback || template, nil)
183
+ end
184
+ end
185
+
186
+ end
@@ -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,7 +80,9 @@ 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, keyword_init: true) do
83
+ :default_origin, :aspect_ratio, :background, :logo, :scrim,
84
+ :header, :header_fallback, :subtext, :subject,
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.
@@ -103,20 +111,50 @@ module Studio
103
111
  label: "Magic-link sign-in",
104
112
  description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
105
113
  default_asset: "emails/magic-link.gif",
106
- aspect_ratio: 3.0
114
+ aspect_ratio: 3.0,
115
+ # Layered artwork: the background animates, the greeting is live HTML on
116
+ # top. default_asset above stays the flat <img> for a mailer that has not
117
+ # adopted the layered banner.
118
+ background: "emails/magic-link-background.gif",
119
+ logo: "emails/logo-horizontal.png",
120
+ # The DEFAULT wording, overridable per app on /admin/emails. {name} is
121
+ # filled from whoever the mailer says the recipient is.
122
+ header: "Welcome {name}!",
123
+ header_fallback: "Your Magic Link",
124
+ subtext: "your sign-in link is below",
125
+ subject: "Your {app} sign-in link"
107
126
  },
108
127
  {
109
- key: "email_change_confirmation",
110
- label: "Email change confirmation",
111
- description: "Confirms a new address before the change takes effect.",
112
- default_asset: "emails/email-change-confirmation.gif",
113
- aspect_ratio: 3.0
128
+ key: "newsletter_subscribed",
129
+ label: "Newsletter subscribed",
130
+ description: "Welcomes someone who has just joined the mailing list.",
131
+ aspect_ratio: 3.0,
132
+ # LAYERED-NATIVE: no default_asset. A flat asset is the pre-layered
133
+ # fallback — artwork with the words baked in, for a mailer that only
134
+ # knows how to render an <img>. Studio::NewsletterMailer has known how to
135
+ # layer since the day it was written, so a baked-in copy of the same
136
+ # picture would be a second thing to keep in sync and never be shown.
137
+ background: "emails/newsletter-subscribed-background.gif",
138
+ logo: "emails/logo-horizontal.png",
139
+ header: "Welcome {name}!",
140
+ header_fallback: "You're subscribed!",
141
+ subtext: "you're on the list",
142
+ subject: "You're subscribed to {app}",
143
+ # The engine ships this email's preview because it can: the mailer takes
144
+ # a bare address, so no host sample data is involved. Every other entry's
145
+ # builder needs records only the host has — this one does not, and an
146
+ # inherited email with no preview is a row on every app's manager that
147
+ # cannot be looked at.
148
+ preview: -> { Studio::NewsletterMailer.subscribed("preview@example.com", name: "Alex") }
114
149
  }
115
150
  ].freeze
116
151
 
117
- # Banners render full-bleed at 600px in a 600px card. 1200x600 is the
118
- # right cut: 2:1, retina-sharp at render width, and small enough to stay
119
- # out of an inbox clipping limit.
152
+ # The FALLBACK shape, for an email that states none. 2:1 because that is what
153
+ # turf-monster's eight banners are, and changing it would recrop all of them.
154
+ #
155
+ # It is NOT what the engine's own emails use: both STANDARD entries declare
156
+ # aspect_ratio: 3.0 and both shipped backgrounds are 1200x400. The ratio is
157
+ # per-entry precisely so those two answers can differ.
120
158
  ASPECT_RATIO = 2.0
121
159
  MAX_WIDTH = 1200
122
160
 
@@ -130,7 +168,8 @@ module Studio
130
168
  # existing value — that is what lets a host relabel an inherited email, or
131
169
  # attach a preview builder to it, without restating its artwork.
132
170
  def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil,
133
- aspect_ratio: nil)
171
+ aspect_ratio: nil, background: nil, logo: nil, scrim: nil,
172
+ header: nil, header_fallback: nil, subtext: nil, subject: nil)
134
173
  key = key.to_s
135
174
  existing = registry[key]
136
175
  registry[key] = Entry.new(
@@ -145,7 +184,14 @@ module Studio
145
184
  # had, so a host relabelling an inherited email does not accidentally
146
185
  # claim the engine's picture as its own.
147
186
  default_origin: default_asset.nil? ? (existing&.default_origin || :engine) : :app,
148
- aspect_ratio: aspect_ratio || existing&.aspect_ratio
187
+ aspect_ratio: aspect_ratio || existing&.aspect_ratio,
188
+ background: background.nil? ? existing&.background : background.presence,
189
+ logo: logo.nil? ? existing&.logo : logo.presence,
190
+ scrim: scrim.nil? ? existing&.scrim : scrim,
191
+ header: header.nil? ? existing&.header : header.presence,
192
+ header_fallback: header_fallback.nil? ? existing&.header_fallback : header_fallback.presence,
193
+ subtext: subtext.nil? ? existing&.subtext : subtext.presence,
194
+ subject: subject.nil? ? existing&.subject : subject.presence
149
195
  )
150
196
  key
151
197
  end
@@ -202,7 +248,9 @@ module Studio
202
248
  @registry ||= STANDARD.each_with_object({}) do |attrs, out|
203
249
  out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]),
204
250
  preview: attrs[:preview], default_origin: :engine,
205
- aspect_ratio: attrs[:aspect_ratio])
251
+ aspect_ratio: attrs[:aspect_ratio],
252
+ background: attrs[:background], logo: attrs[:logo],
253
+ scrim: attrs[:scrim])
206
254
  end
207
255
  end
208
256
 
@@ -239,6 +287,110 @@ module Studio
239
287
  # This email's banner shape, falling back to the shared default.
240
288
  def ratio(key) = entry(key)&.ratio || ASPECT_RATIO
241
289
 
290
+ # --- layered banner artwork ---------------------------------------------
291
+ #
292
+ # Absolute URLs, because a mail client fetches these from an inbox and a
293
+ # root-relative path resolves against nothing there.
294
+
295
+ # Saved by the operator > registered by the app > engine default.
296
+ def scrim(key)
297
+ Studio::EmailSetting.scrim_for(key) || entry(key)&.scrim
298
+ rescue StandardError
299
+ entry(key)&.scrim
300
+ end
301
+
302
+ # --- the banner's words -------------------------------------------------
303
+ #
304
+ # Same order as the tint, for the same reason: the operator is the one
305
+ # looking at the artwork. Each falls back to the registry, then to a
306
+ # sensible default, so an email that nobody has configured still reads.
307
+
308
+ # The header TEMPLATE — it may contain {name}. Interpolation happens in
309
+ # Studio::Banner, which is the only place that knows the recipient.
310
+ def header_template(key)
311
+ saved(key, :header) || entry(key)&.header || entry(key)&.label
312
+ end
313
+
314
+ # What the header says when no name is known. A magic link is often the
315
+ # first contact we have with someone, so "Welcome {name}!" must have
316
+ # somewhere to land that is not "Welcome !".
317
+ def header_fallback(key)
318
+ saved(key, :header_fallback) || entry(key)&.header_fallback || entry(key)&.label
319
+ end
320
+
321
+ def subtext(key)
322
+ saved(key, :subtext) || entry(key)&.subtext
323
+ end
324
+
325
+ # The subject line, resolved the same way and supporting the same {name}
326
+ # placeholder. A mailer calls this instead of hard-coding a string, which is
327
+ # what makes the field on /admin/emails real rather than decorative.
328
+ def subject_for(key, name: nil)
329
+ template = saved(key, :subject) || entry(key)&.subject
330
+ return nil if template.blank?
331
+
332
+ Studio::Banner.interpolate(template, name).presence
333
+ end
334
+
335
+ # nil when the operator has hidden the logo — distinct from "none saved",
336
+ # which inherits the registry's.
337
+ # Hidden > uploaded here > a URL the operator typed > the registry's.
338
+ # "Hidden" comes first because it is the one answer the others cannot express.
339
+ def resolved_logo_url(key)
340
+ return nil if Studio::EmailSetting.hide_logo?(key)
341
+
342
+ uploaded_logo_url(key) || saved(key, :logo_url) || logo_url(key)
343
+ rescue StandardError
344
+ logo_url(key)
345
+ end
346
+
347
+ # An operator-saved field, or nil. Rescues because these are read on a
348
+ # delivery path: a settings table that is missing, locked, or mid-migration
349
+ # must degrade to the registry default rather than fail the send.
350
+ def saved(key, field)
351
+ Studio::EmailSetting.copy_for(key, field)
352
+ rescue StandardError
353
+ nil
354
+ end
355
+
356
+ def scrim_percent(key)
357
+ value = scrim(key) || Studio::Banner::DEFAULT_SCRIM
358
+ (value.to_f * 100).round
359
+ end
360
+
361
+ # THE APP'S OWN UPLOAD WINS, then the registered artwork. Same two layers as
362
+ # resolved_url, and for the same reason: uploading on /admin/emails is how an
363
+ # operator says "this picture is ours now".
364
+ #
365
+ # Reading only the registry made the Upload button a control that lies on a
366
+ # LAYERED email — the upload landed, the page showed it, the provenance badge
367
+ # flipped to "Uploaded here", and the email kept sending the gem's artwork
368
+ # because the layered banner never looked at the row.
369
+ #
370
+ # NIL WHEN THIS APP OWNS THE ARTWORK. A host registering its own flat
371
+ # default_asset sends that picture; the background it also inherited is the
372
+ # engine's and nothing sends it. The list row carried this guard alone, so
373
+ # the detail page still layered live text over artwork no inbox receives.
374
+ def background_url(key)
375
+ return nil unless entry(key)&.engine_artwork?
376
+
377
+ url(key) || absolute_asset_url(entry(key)&.background)
378
+ end
379
+ def logo_url(key) = absolute_asset_url(entry(key)&.logo)
380
+
381
+ def absolute_asset_url(asset)
382
+ return nil if asset.blank?
383
+
384
+ path = ActionController::Base.helpers.asset_path(asset)
385
+ return nil if path.blank?
386
+ return path if path.start_with?("http")
387
+
388
+ host = mailer_asset_host
389
+ host ? "#{host}#{path}" : path
390
+ rescue StandardError
391
+ nil
392
+ end
393
+
242
394
  def app_owned?(key) = source(key) == :app
243
395
 
244
396
  # --- Preview -----------------------------------------------------------
@@ -348,7 +500,31 @@ module Studio
348
500
  # whatever host and port this app is being viewed on (an absolute mailer
349
501
  # asset_host is set for the inbox, not for a browser on localhost:3042).
350
502
  def preview_url(key)
351
- url(key) || default_asset_path(key)
503
+ url(key) || preview_asset_path(key)
504
+ end
505
+
506
+ # What the manager DRAWS, which is a different question from what the flat
507
+ # <img> fallback sends — so it resolves in the opposite order.
508
+ #
509
+ # LAYERED FIRST. magic_link ships both: `emails/magic-link.gif`, the old
510
+ # banner with "Your Magic Link" baked into the picture, and
511
+ # `emails/magic-link-background.gif`, the artwork the layered banner draws
512
+ # live text on top of. A mailer that has adopted layering sends the SECOND
513
+ # one — so previewing the first showed the operator a picture no inbox
514
+ # receives, and did it convincingly, because baked-in words look like a real
515
+ # banner. Same failure as the "No image" badge, one door further along: the
516
+ # page answering from the field it happened to read instead of from what
517
+ # ships.
518
+ #
519
+ # The flat asset stays the fallback, for a host still on the engine's own
520
+ # unlayered UserMailer — there, the baked-text banner IS what arrives.
521
+ #
522
+ # Unless THIS APP owns the artwork, in which case it previews exactly what
523
+ # the flat resolution sends — same method, so the two cannot disagree.
524
+ def preview_asset_path(key)
525
+ return default_asset_path(key) unless entry(key)&.engine_artwork?
526
+
527
+ asset_path(entry(key)&.background.presence || entry(key)&.default_asset)
352
528
  end
353
529
 
354
530
  # The ImageCache row holding this app's override, or nil (nothing uploaded /
@@ -359,14 +535,60 @@ module Studio
359
535
  ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
360
536
  end
361
537
 
362
- # Root-relative path to the inherited default asset, or nil when the email
363
- # has no default registered or the host's pipeline cannot resolve it.
538
+ def logo_record(key)
539
+ return nil unless table_ready?
540
+
541
+ ::ImageCache.find_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
542
+ end
543
+
544
+ # An uploaded logo for this email, or nil to inherit.
545
+ def uploaded_logo_url(key)
546
+ logo_record(key)&.url
547
+ rescue StandardError
548
+ nil
549
+ end
550
+
551
+ def store_logo(key, io:, content_type: nil)
552
+ s3_key = "email_logos/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
553
+ Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
554
+ cache_control: "public, max-age=300")
555
+ record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
556
+ previous = record.s3_key
557
+ record.update!(s3_key: s3_key)
558
+ delete_object(previous) if previous.present? && previous != s3_key
559
+ record
560
+ rescue StandardError
561
+ delete_object(s3_key)
562
+ raise
563
+ end
564
+
565
+ def revert_logo(key)
566
+ row = logo_record(key)
567
+ return false if row.nil?
568
+
569
+ previous = row.s3_key
570
+ row.destroy!
571
+ delete_object(previous) if previous.present?
572
+ true
573
+ end
574
+
575
+ # Root-relative path to the FLAT artwork — what the <img> fallback sends.
576
+ # Flat first, then the layered background as a last resort so a
577
+ # layered-native email (newsletter_subscribed registers no flat asset,
578
+ # because it never renders one) still has something rather than nothing.
579
+ # See preview_asset_path above for why the manager resolves the other way.
364
580
  def default_asset_path(key)
365
- asset = entry(key)&.default_asset
581
+ asset_path(entry(key)&.default_asset.presence || entry(key)&.background)
582
+ end
583
+
584
+ # Shared tail of both resolutions: a logical asset name to a root-relative
585
+ # path, or nil when there is no asset or the host's pipeline cannot resolve
586
+ # it. Rescues broadly because a missing asset must degrade to "no image",
587
+ # never take the manager down.
588
+ def asset_path(asset)
366
589
  return nil if asset.nil? || asset.empty?
367
590
 
368
- path = ActionController::Base.helpers.asset_path(asset)
369
- path.presence
591
+ ActionController::Base.helpers.asset_path(asset).presence
370
592
  rescue StandardError
371
593
  nil
372
594
  end
@@ -477,11 +699,18 @@ module Studio
477
699
 
478
700
  LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
479
701
 
702
+ # GIF is listed because animated banners are uploaded whole — they bypass the
703
+ # cropper, which would flatten them to a single PNG frame. Without this branch
704
+ # a GIF was stored under a ".png" key: the object's Content-Type was still
705
+ # image/gif so it played, but the URL said otherwise, and anything that trusts
706
+ # an extension (a CDN, a proxy, a person reading the bucket) was told the
707
+ # wrong thing.
480
708
  def ext_for(content_type)
481
709
  case content_type.to_s
482
710
  when %r{png} then ".png"
483
711
  when %r{jpe?g} then ".jpg"
484
712
  when %r{webp} then ".webp"
713
+ when %r{gif} then ".gif"
485
714
  else ".png"
486
715
  end
487
716
  end