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
@@ -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, keyword_init: true) do
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: 3.0
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: "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
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
- # 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.
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
- aspect_ratio: aspect_ratio || existing&.aspect_ratio
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) || default_asset_path(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
- # 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.
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
- asset = entry(key)&.default_asset
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
- path = ActionController::Base.helpers.asset_path(asset)
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