Module: Studio::EmailCatalog

Defined in:
app/services/studio/email_catalog.rb

Overview

The shared email catalog: every email an app sends, what kind it is, how to build a live preview of it, and the banner image it ships with.

Named for the prior art it absorbs. turf-monster built ::EmailCatalog + Admin::EmailsController first and left a note on both saying this manager "moves into the shared studio-engine email framework (Phase 2)". This is Phase 2 — so the engine takes the name, the shape (key / name / type / description / preview builder), and the live-preview page, and adds the banner-image half. turf-monster then deletes its copy instead of running two email pages side by side.

Was Studio::EmailImage, which is now a delegating shim — see app/services/studio/email_image.rb. The old name outlived its meaning the moment an entry carried a type and a preview builder alongside its image.

Two layers: inherited default, app-owned override

.resolved_url(key) => app's own ImageCache row (its S3 bucket)  # app-owned
                 -> the engine's default gem asset            # inherited
                 -> nil                                       # no image

.url(key) stays the PRE-REGISTRY contract — this app's own image or nil — so every caller written before the registry keeps its behavior until its app adopts. See the note on #url; getting this wrong swaps a host's committed artwork for the engine placeholder in live email.

Defaults RIDE THE GEM (app/assets/images/emails/*), so a brand-new app with an empty bucket sends good-looking email on day one and needs no cross-app S3 permission. Uploading on an app's /admin/emails writes to THAT app's bucket and THAT app's ImageCache row — which is exactly "the asset now belongs to this app". Every app has its own bucket and its own image_caches table, so an override never leaks between apps.

Registering

The engine pre-registers the two every Studio app sends (STANDARD below), so hosts inherit them without declaring anything. A host adds its own workflows from an initializer, mirroring Studio::ModelPage.register:

# config/initializers/studio_emails.rb
Rails.application.config.to_prepare do
Studio::EmailCatalog.register("winnings",
  label: "Contest winnings",
  description: "Sent when a player wins a contest.",
  type: :transactional,
  preview: -> { ContestMailer.winnings(Entry.where.not(rank: nil).first) })
end

Re-registering a key updates it in place and keeps its position, so a host can relabel an inherited email without reordering the page.

Preview

preview is a callable returning a Mail — the app builds it from whatever sample data it likes. It is what powers the live preview on /admin/emails/:key. It runs ONLY on that admin page, never in a delivery path, and every call is wrapped: an entry whose builder raises shows the error on the page rather than 500ing the manager. An entry without one still lists and still manages its banner; it just has nothing to preview.

Defined Under Namespace

Classes: Entry

Constant Summary collapse

PURPOSE =
"email_banner".freeze
LOGO_PURPOSE =

The LOGO is a separate purpose, not a variant of the banner. They are different pictures with different rules: a banner is 3:1 artwork that may be an animated GIF, a logo is a small transparent mark. Sharing one purpose would make "revert the banner" and "revert the logo" the same row.

"email_logo".freeze
TYPES =

What an email is FOR. Transactional = sent in response to something the recipient did; marketing = sent because we decided to. Kept because turf-monster's catalog carried it and the distinction drives real policy (unsubscribe requirements, send-time rules, which from-address is used).

%i[transactional marketing].freeze
DEFAULT_TYPE =
:transactional
STANDARD =

The emails EVERY Studio app sends. Pre-registered, so a host inherits both without declaring anything.

[
  {
    key: "magic_link",
    label: "Magic-link sign-in",
    description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
    default_asset: "emails/magic-link.gif",
    aspect_ratio: 2.0,
    # Layered artwork: the background animates, the greeting is live HTML on
    # top. default_asset above stays the flat <img> for a mailer that has not
    # adopted the layered banner.
    background: "emails/magic-link-background.gif",
    # NO logo: KEY — the same rule newsletter_subscribed above follows, and
    # for the same reason. It seeded "emails/logo-horizontal.png" (the
    # McRITCHIE STUDIO wordmark) into every host that registers this email
    # without naming a mark, because register() merges on `logo.presence`
    # and an omitted key INHERITS. On the SIGN-IN email, which is the most-
    # sent email most of these apps have. Full reasoning under NO DEFAULT
    # LOGO below; the guards are in test/integration/magic_link_logo_test.rb.
    # The DEFAULT wording, overridable per app on /admin/emails. {name} is
    # filled from whoever the mailer says the recipient is.
    header: "Welcome {name}!",
    header_fallback: "Your Magic Link",
    subtext: "your sign-in link is below",
    subject: "Your {app} sign-in link",
    body: "Tap the button below to sign in to {app} — no password needed. " \
          "If you don't have an account yet, we'll create one for you.",
    # The token URL is the destination, so this email really does have a button.
    supports_cta: true,
    cta_text: "Sign in to {app}"
  },
  {
    key: "newsletter_subscribed",
    label: "Newsletter subscribed",
    description: "Welcomes someone who has just joined the mailing list.",
    aspect_ratio: 2.0,
    # LAYERED-NATIVE: no default_asset. A flat asset is the pre-layered
    # fallback — artwork with the words baked in, for a mailer that only
    # knows how to render an <img>. Studio::NewsletterMailer has known how to
    # layer since the day it was written, so a baked-in copy of the same
    # picture would be a second thing to keep in sync and never be shown.
    background: "emails/newsletter-subscribed-background.gif",
    # NO logo: KEY, and adding one back is the bug. It seeded
    # "emails/logo-horizontal.png" — the McRITCHIE STUDIO wordmark — into
    # every host that registers this email without naming a logo of its own,
    # because register() merges on `logo.presence` and an omitted key
    # inherits. Full reasoning under NO DEFAULT LOGO below; the guards are in
    # test/integration/newsletter_email_test.rb.
    header: "Welcome {name}!",
    header_fallback: "You're subscribed!",
    subtext: "you're on the list",
    subject: "You're subscribed to {app}",
    body: "Thanks for subscribing to {app}. We'll send the occasional note " \
          "about what we're building — no more often than it's worth your time.",
    # NO BUTTON, and no card offering one: a new subscriber has nowhere to be
    # sent, so subscribed.html.erb renders none and never will.
    supports_cta: false,
    # The engine ships this email's preview because it can: the mailer takes
    # a bare address, so no host sample data is involved. Every other entry's
    # builder needs records only the host has — this one does not, and an
    # inherited email with no preview is a row on every app's manager that
    # cannot be looked at.
    preview: -> { Studio::NewsletterMailer.subscribed("preview@example.com", name: "Alex") }
  },
  {
    key: "email_change_notification",
    label: "Email change — heads up",
    description: "Sent to the OLD address after a change lands, so an unauthorized change is visible rather than silent.",
    # Reuses the shipped email-change artwork. The asset is named for a
    # confirmation email that no longer exists; the picture is right for the
    # subject either way, and renaming a shipped asset is its own migration.
    default_asset: "emails/email-change-confirmation.gif",
    header: "Your email was changed",
    header_fallback: "Your email was changed",
    subtext: "a heads-up from {app}",
    subject: "Your {app} email was changed",
    body: "The email address on your {app} account was just changed. " \
          "If you did not do this, contact us straight away — this message " \
          "was sent to your previous address on purpose.",
    # No button: there is nothing to click. The point is the notice itself.
    supports_cta: false,
    preview: lambda {
      Studio::ProfileMailer.email_change_notification(nil, "old@example.com", "new@example.com")
    }
  }
].freeze
ASPECT_RATIO =

The FALLBACK shape, for an email that states none. 2:1 because that is what turf-monster's eight banners are, and changing it would recrop all of them.

The engine's own two entries now declare 2.0 as well, and their backgrounds are 1200x600 — but the ratio stays per-entry, because that is what lets a host register artwork of a different shape without recropping everyone's.

2.0
MAX_WIDTH =
1200
"#1A1535".freeze
LOOPBACK_HOSTS =
%w[localhost 127.0.0.1 0.0.0.0 ::1].freeze

Class Method Summary collapse

Class Method Details

.absolute_asset_url(asset) ⇒ Object



611
612
613
614
615
616
617
618
619
620
621
622
# File 'app/services/studio/email_catalog.rb', line 611

def absolute_asset_url(asset)
  return nil if asset.blank?

  path = ActionController::Base.helpers.asset_path(asset)
  return nil if path.blank?
  return path if path.start_with?("http")

  host = mailer_asset_host
  host ? "#{host}#{path}" : path
rescue StandardError
  nil
end

.app_artwork?(key) ⇒ Boolean

True when the live banner belongs to this app either way — uploaded here or committed here. What the page's summary line counts.

Returns:

  • (Boolean)


453
# File 'app/services/studio/email_catalog.rb', line 453

def app_artwork?(key) = %i[app app_asset].include?(source(key))

.app_owned?(key) ⇒ Boolean

Returns:

  • (Boolean)


624
# File 'app/services/studio/email_catalog.rb', line 624

def app_owned?(key) = source(key) == :app

.asset_path(asset) ⇒ Object

Shared tail of both resolutions: a logical asset name to a root-relative path, or nil when there is no asset or the host's pipeline cannot resolve it. Rescues broadly because a missing asset must degrade to "no image", never take the manager down.



827
828
829
830
831
832
833
# File 'app/services/studio/email_catalog.rb', line 827

def asset_path(asset)
  return nil if asset.nil? || asset.empty?

  ActionController::Base.helpers.asset_path(asset).presence
rescue StandardError
  nil
end

.background_url(key) ⇒ Object

THE APP'S OWN UPLOAD WINS, then the registered artwork. Same two layers as resolved_url, and for the same reason: uploading on /admin/emails is how an operator says "this picture is ours now".

Reading only the registry made the Upload button a control that lies on a LAYERED email — the upload landed, the page showed it, the provenance badge flipped to "Uploaded here", and the email kept sending the gem's artwork because the layered banner never looked at the row.

NIL UNLESS THIS EMAIL LAYERS. A host registering its own flat default_asset sends that picture, and the background it merely INHERITED is the engine's — nothing sends it. But a host that registers a background of its OWN is asking to layer, and layered? is what tells the two apart.

This is the ONE place that decision is made. Every reader asks this method rather than re-deriving it: the list row used to carry its own copy of the guard, and the copy went stale the moment the guard moved here.



604
605
606
607
608
# File 'app/services/studio/email_catalog.rb', line 604

def background_url(key)
  return nil unless entry(key)&.layered?

  url(key) || absolute_asset_url(entry(key)&.background)
end

.body(key, name: nil) ⇒ Object

--- the email below the banner ------------------------------------------

Same resolution as the banner's words: operator > registry > default. A mailer reads these instead of hard-coding copy, which is what makes the cards on /admin/emails real rather than decorative.



499
500
501
502
503
504
# File 'app/services/studio/email_catalog.rb', line 499

def body(key, name: nil)
  template = saved(key, :body) || entry(key)&.body
  return nil if template.blank?

  Studio::Banner.interpolate(template, name).presence
end

.cta_color(key) ⇒ Object

The app's primary unless this email says otherwise — a button that matches the banner above it by default, and can be made to stand out per email.



515
516
517
# File 'app/services/studio/email_catalog.rb', line 515

def cta_color(key)
  saved(key, :cta_color) || entry(key)&.cta_color || Studio.theme_primary
end

.cta_enabled?(key) ⇒ Boolean

Shown unless someone said no. Defaults to TRUE for an email that has CTA text, because the button is the point of a transactional email; an email with no text has nothing to render either way.

Returns:

  • (Boolean)


522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
# File 'app/services/studio/email_catalog.rb', line 522

def cta_enabled?(key)
  # An email whose template cannot render a button is never "enabled", no
  # matter what is stored — otherwise a value saved before the capability was
  # declared keeps claiming a button that cannot appear.
  return false unless entry(key)&.supports_cta?

  operator = begin
    Studio::EmailSetting.cta_enabled_for(key)
  rescue StandardError
    nil
  end
  return operator unless operator.nil?

  registered = entry(key)&.cta_enabled
  return registered unless registered.nil?

  true
end

.cta_text(key, name: nil) ⇒ Object



506
507
508
509
510
511
# File 'app/services/studio/email_catalog.rb', line 506

def cta_text(key, name: nil)
  template = saved(key, :cta_text) || entry(key)&.cta_text
  return nil if template.blank?

  Studio::Banner.interpolate(template, name).presence
end

.default_asset_path(key) ⇒ Object

Root-relative path to the FLAT artwork — what the fallback sends. Flat first, then the layered background as a last resort so a layered-native email (newsletter_subscribed registers no flat asset, because it never renders one) still has something rather than nothing. See preview_asset_path above for why the manager resolves the other way.



819
820
821
# File 'app/services/studio/email_catalog.rb', line 819

def default_asset_path(key)
  asset_path(entry(key)&.default_asset.presence || entry(key)&.background)
end

.default_url(key) ⇒ Object

Absolute URL to the inherited default asset — what a mailer needs. Uses action_mailer.asset_host (set per env), falling back to the mailer's default_url_options host. Returns the bare path if neither is configured, which still renders in the local inbox preview.



839
840
841
842
843
844
845
846
# File 'app/services/studio/email_catalog.rb', line 839

def default_url(key)
  path = default_asset_path(key)
  return nil if path.nil?
  return path if path.start_with?("http")

  host = mailer_asset_host
  host ? "#{host}#{path}" : path
end

.delete_object(key) ⇒ Object



957
958
959
960
961
# File 'app/services/studio/email_catalog.rb', line 957

def delete_object(key)
  Studio::S3.delete(key: key)
rescue StandardError
  nil
end

.entriesObject

Every registered email, in display order: the standard two first, then the host's own in declaration order.



376
377
378
# File 'app/services/studio/email_catalog.rb', line 376

def entries
  registry.values
end

.entry(key) ⇒ Object



380
381
382
# File 'app/services/studio/email_catalog.rb', line 380

def entry(key)
  registry[key.to_s]
end

.ext_for(content_type) ⇒ Object

GIF is listed because animated banners are uploaded whole — they bypass the cropper, which would flatten them to a single PNG frame. Without this branch a GIF was stored under a ".png" key: the object's Content-Type was still image/gif so it played, but the URL said otherwise, and anything that trusts an extension (a CDN, a proxy, a person reading the bucket) was told the wrong thing.



947
948
949
950
951
952
953
954
955
# File 'app/services/studio/email_catalog.rb', line 947

def ext_for(content_type)
  case content_type.to_s
  when %r{png}    then ".png"
  when %r{jpe?g}  then ".jpg"
  when %r{webp}   then ".webp"
  when %r{gif}    then ".gif"
  else ".png"
  end
end

The shared footer — the same on every email this app sends.

The operator's footer, or nothing. An app that has not set one renders no band at all — the engine ships no branding of its own into a host's email.



545
546
547
548
549
# File 'app/services/studio/email_catalog.rb', line 545

def footer
  (Studio::EmailSetting.footer || {}).compact
rescue StandardError
  {}
end

.header_fallback(key) ⇒ Object

What the header says when no name is known. A magic link is often the first contact we have with someone, so "Welcome name!" must have somewhere to land that is not "Welcome !".



485
486
487
# File 'app/services/studio/email_catalog.rb', line 485

def header_fallback(key)
  saved(key, :header_fallback) || entry(key)&.header_fallback || entry(key)&.label
end

.header_template(key) ⇒ Object

The header TEMPLATE — it may contain name. Interpolation happens in Studio::Banner, which is the only place that knows the recipient.



478
479
480
# File 'app/services/studio/email_catalog.rb', line 478

def header_template(key)
  saved(key, :header) || entry(key)&.header || entry(key)&.label
end

.keysObject



384
385
386
# File 'app/services/studio/email_catalog.rb', line 384

def keys
  registry.keys
end

.known?(key) ⇒ Boolean

Returns:

  • (Boolean)


388
389
390
# File 'app/services/studio/email_catalog.rb', line 388

def known?(key)
  registry.key?(key.to_s)
end

.label(key) ⇒ Object



393
394
395
# File 'app/services/studio/email_catalog.rb', line 393

def label(key)
  entry(key)&.label || key.to_s.humanize
end

.logo_record(key) ⇒ Object



777
778
779
780
781
# File 'app/services/studio/email_catalog.rb', line 777

def logo_record(key)
  return nil unless table_ready?

  ::ImageCache.find_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
end

.logo_url(key) ⇒ Object



609
# File 'app/services/studio/email_catalog.rb', line 609

def logo_url(key)       = absolute_asset_url(entry(key)&.)

.mailer_asset_hostObject

The origin an email's banner URL hangs off. action_mailer.asset_host when the host sets one (turf-monster does, per env); otherwise built from the mailer's default_url_options.

That fallback has to reconstruct a real origin, not just the hostname. default_url_options is routinely "localhost", port: 3001 — taking :host alone and prefixing "https://&quot; yields https://localhost, which is the wrong scheme AND the wrong port, and the banner comes back ERR_CONNECTION_REFUSED. Caught by opening the preview page on a worktree stack; every dev/QA preview took that path.



906
907
908
909
910
911
912
913
914
915
916
917
918
# File 'app/services/studio/email_catalog.rb', line 906

def mailer_asset_host
  configured = Rails.application.config.action_mailer.asset_host.presence
  return configured if configured

  options = ActionMailer::Base.default_url_options || {}
  host = options[:host].presence
  return nil if host.nil?
  return host if host.start_with?("http")

  "#{mailer_protocol(options, host)}://#{host}#{mailer_port_suffix(options)}"
rescue StandardError
  nil
end

.mailer_port_suffix(options) ⇒ Object

Ports are part of the origin, and omitting one sends the reader to :443. The scheme defaults are left off so a normal URL stays normal.



932
933
934
935
936
937
# File 'app/services/studio/email_catalog.rb', line 932

def mailer_port_suffix(options)
  port = options[:port]
  return "" if port.blank? || [80, 443].include?(port.to_i)

  ":#{port}"
end

.mailer_protocol(options, host) ⇒ Object

Honor an explicit :protocol. Otherwise https — EXCEPT on loopback, which is a dev stack with no TLS. Defaulting the other way would downgrade every production app that sets only "mcritchie.studio".



923
924
925
926
927
928
# File 'app/services/studio/email_catalog.rb', line 923

def mailer_protocol(options, host)
  explicit = options[:protocol].presence
  return explicit.to_s.sub(%r{://\z}, "") if explicit

  LOOPBACK_HOSTS.include?(host.downcase) ? "http" : "https"
end

.normalize_type(type) ⇒ Object

Unknown types fall back to :transactional rather than raising — a typo in an initializer must not take the host's boot down over a display label.



369
370
371
372
# File 'app/services/studio/email_catalog.rb', line 369

def normalize_type(type)
  symbol = type.to_s.strip.downcase.to_sym
  TYPES.include?(symbol) ? symbol : DEFAULT_TYPE
end

.preview_asset_path(key) ⇒ Object

What the manager DRAWS, which is a different question from what the flat fallback sends — so it resolves in the opposite order.

LAYERED FIRST. magic_link ships both: emails/magic-link.gif, the old banner with "Your Magic Link" baked into the picture, and emails/magic-link-background.gif, the artwork the layered banner draws live text on top of. A mailer that has adopted layering sends the SECOND one — so previewing the first showed the operator a picture no inbox receives, and did it convincingly, because baked-in words look like a real banner. Same failure as the "No image" badge, one door further along: the page answering from the field it happened to read instead of from what ships.

The flat asset stays the fallback, for a host still on the engine's own unlayered UserMailer — there, the baked-text banner IS what arrives.

Unless this email does not LAYER, in which case it previews exactly what the flat resolution sends — same method, so the two cannot disagree.

THE SAME QUESTION background_url ASKS, so it must ask it the same way. This guard read engine_artwork? while background_url read layered?, and the two answer differently for a host that registers its own background: the mailer sent the layered banner while /admin/email_images and the detail page's "Artwork" frame both drew the flat asset. That frame is where "Modify image" lives, and an upload writes the row background_url reads FIRST — so the operator was shown one picture and told it was the one the button would replace.



763
764
765
766
767
# File 'app/services/studio/email_catalog.rb', line 763

def preview_asset_path(key)
  return default_asset_path(key) unless entry(key)&.layered?

  asset_path(entry(key)&.background.presence || entry(key)&.default_asset)
end

.preview_error(key) ⇒ Object

The reason the last preview_mail(key) returned nil, or nil if it did not fail. Set by preview_mail; read by the page so it can say WHY.



657
658
659
# File 'app/services/studio/email_catalog.rb', line 657

def preview_error(key)
  (@preview_errors ||= {})[key.to_s]
end

.preview_html(key) ⇒ Object

The rendered HTML body of the preview, for the iframe. nil when the email has no builder or the builder failed.



663
664
665
666
667
668
669
670
671
# File 'app/services/studio/email_catalog.rb', line 663

def preview_html(key)
  mail = preview_mail(key)
  return nil if mail.nil?

  (mail.html_part&.body || mail.body).to_s
rescue StandardError => e
  (@preview_errors ||= {})[key.to_s] = "#{e.class}: #{e.message}"
  nil
end

.preview_mail(key) ⇒ Object

Build the sample Mail for this email, or nil.

NEVER raises. A preview builder is host code running against whatever sample data happens to be in this environment — an empty table, a fixture that moved, a mailer whose signature changed. Any of those must show up as a message ON the preview page, not as a 500 that takes the whole email manager down with it. Returns nil; ask #preview_error for the reason.



643
644
645
646
647
648
649
650
651
652
653
# File 'app/services/studio/email_catalog.rb', line 643

def preview_mail(key)
  callable = entry(key)&.preview
  return nil unless callable.respond_to?(:call)

  @preview_errors ||= {}
  @preview_errors.delete(key.to_s)
  force_message(callable.call)
rescue StandardError, ScriptError => e
  (@preview_errors ||= {})[key.to_s] = "#{e.class}: #{e.message}"
  nil
end

.preview_subject(key) ⇒ Object



673
674
675
# File 'app/services/studio/email_catalog.rb', line 673

def preview_subject(key)
  preview_mail(key)&.subject
end

.preview_url(key) ⇒ Object

What the ADMIN PAGE previews. Same two layers as resolved_url, but a default stays a root-relative asset path so it renders correctly on whatever host and port this app is being viewed on (an absolute mailer asset_host is set for the inbox, not for a browser on localhost:3042).



732
733
734
# File 'app/services/studio/email_catalog.rb', line 732

def preview_url(key)
  url(key) || preview_asset_path(key)
end

.previewable?(key) ⇒ Boolean

--- Preview -----------------------------------------------------------

Returns:

  • (Boolean)


628
629
630
# File 'app/services/studio/email_catalog.rb', line 628

def previewable?(key)
  entry(key)&.previewable? || false
end

.ratio(key) ⇒ Object

This email's banner shape, falling back to the shared default.



456
# File 'app/services/studio/email_catalog.rb', line 456

def ratio(key) = entry(key)&.ratio || ASPECT_RATIO

.record(key) ⇒ Object

The ImageCache row holding this app's override, or nil (nothing uploaded / table not installed yet). Nil-safe so the mailer renders before any upload.



771
772
773
774
775
# File 'app/services/studio/email_catalog.rb', line 771

def record(key)
  return nil unless table_ready?

  ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
end

.register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil, aspect_ratio: nil, background: nil, logo: nil, scrim: nil, header: nil, header_fallback: nil, subtext: nil, subject: nil, body: nil, cta_text: nil, cta_color: nil, cta_enabled: nil, supports_cta: nil) ⇒ Object

Register (or update) an email workflow. Returns the key.

Every keyword is OPTIONAL and omitting one on a re-register KEEPS the existing value — that is what lets a host relabel an inherited email, or attach a preview builder to it, without restating its artwork.



327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'app/services/studio/email_catalog.rb', line 327

def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil,
             aspect_ratio: nil, background: nil, logo: nil, scrim: nil,
             header: nil, header_fallback: nil, subtext: nil, subject: nil,
             body: nil, cta_text: nil, cta_color: nil, cta_enabled: nil, supports_cta: nil)
  key = key.to_s
  existing = registry[key]
  registry[key] = Entry.new(
    key: key,
    label: label || existing&.label || key.humanize,
    description: description || existing&.description,
    default_asset: default_asset.nil? ? existing&.default_asset : default_asset.presence,
    type: normalize_type(type || existing&.type),
    preview: preview || existing&.preview,
    # Passing artwork here makes it THIS APP's artwork — that is the only
    # moment anyone can know. Omitting it keeps whatever the entry already
    # had, so a host relabelling an inherited email does not accidentally
    # claim the engine's picture as its own.
    default_origin: default_asset.nil? ? (existing&.default_origin || :engine) : :app,
    # WHOSE background this is, recorded the same way and for the same
    # reason: by the time anyone asks, an inherited background and a
    # host-registered one are indistinguishable, and guessing is what this
    # records to avoid. A host passing background: is ASKING to layer.
    background_origin: background.nil? ? (existing&.background_origin || :engine) : :app,
    aspect_ratio: aspect_ratio || existing&.aspect_ratio,
    background: background.nil? ? existing&.background : background.presence,
    logo: .nil? ? existing&. : .presence,
    scrim: scrim.nil? ? existing&.scrim : scrim,
    header: header.nil? ? existing&.header : header.presence,
    header_fallback: header_fallback.nil? ? existing&.header_fallback : header_fallback.presence,
    subtext: subtext.nil? ? existing&.subtext : subtext.presence,
    subject: subject.nil? ? existing&.subject : subject.presence,
    body: body.nil? ? existing&.body : body.presence,
    cta_text: cta_text.nil? ? existing&.cta_text : cta_text.presence,
    cta_color: cta_color.nil? ? existing&.cta_color : cta_color.presence,
    cta_enabled: cta_enabled.nil? ? existing&.cta_enabled : cta_enabled,
    supports_cta: supports_cta.nil? ? existing&.supports_cta : supports_cta
  )
  key
end

.registered?(key) ⇒ Boolean

Returns:

  • (Boolean)


391
# File 'app/services/studio/email_catalog.rb', line 391

def registered?(key) = known?(key)

.registryObject

Seeded through the SAME normalization register() uses, so a standard entry is indistinguishable from a host-registered one (its type is a real symbol, not nil) and every reader can trust the shape.



415
416
417
418
419
420
421
422
423
# File 'app/services/studio/email_catalog.rb', line 415

def registry
  @registry ||= STANDARD.each_with_object({}) do |attrs, out|
    out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]),
                                 preview: attrs[:preview], default_origin: :engine,
                                 aspect_ratio: attrs[:aspect_ratio],
                                 background: attrs[:background], logo: attrs[:logo],
                                 scrim: attrs[:scrim])
  end
end

.reset!Object

Drops host registrations back to the standard two. For tests and for to_prepare re-registration.



405
406
407
408
409
410
# File 'app/services/studio/email_catalog.rb', line 405

def reset!
  @registry = nil
  @preview_errors = nil
  registry
  nil
end

.resolved_logo_url(key) ⇒ Object

nil when the operator has hidden the logo — distinct from "none saved", which inherits the registry's. Hidden > uploaded here > a URL the operator typed > the registry's. "Hidden" comes first because it is the one answer the others cannot express.



565
566
567
568
569
570
571
# File 'app/services/studio/email_catalog.rb', line 565

def resolved_logo_url(key)
  return nil if Studio::EmailSetting.hide_logo?(key)

  uploaded_logo_url(key) || saved(key, :logo_url) || logo_url(key)
rescue StandardError
  logo_url(key)
end

.resolved_url(key) ⇒ Object

What ACTUALLY SHIPS on this email — the two-layer resolution. Absolute, so it resolves from an inbox. App-owned override first, then the inherited engine default, then nil (the mailer renders bannerless).

This is what a mailer should call once its app has adopted the registry. The engine's own UserMailer already does, which is what gives an app with an empty bucket branded email on day one.



724
725
726
# File 'app/services/studio/email_catalog.rb', line 724

def resolved_url(key)
  url(key) || default_url(key)
end

.revert(key) ⇒ Object

Drop this app's override and fall back to the inherited default. Returns true when a row was removed.



876
877
878
879
880
881
882
883
884
# File 'app/services/studio/email_catalog.rb', line 876

def revert(key)
  row = record(key)
  return false if row.nil?

  previous = row.s3_key
  row.destroy!
  delete_object(previous) if previous.present?
  true
end

.revert_logo(key) ⇒ Object



804
805
806
807
808
809
810
811
812
# File 'app/services/studio/email_catalog.rb', line 804

def (key)
  row = logo_record(key)
  return false if row.nil?

  previous = row.s3_key
  row.destroy!
  delete_object(previous) if previous.present?
  true
end

.saved(key, field) ⇒ Object

An operator-saved field, or nil. Rescues because these are read on a delivery path: a settings table that is missing, locked, or mid-migration must degrade to the registry default rather than fail the send.



576
577
578
579
580
# File 'app/services/studio/email_catalog.rb', line 576

def saved(key, field)
  Studio::EmailSetting.copy_for(key, field)
rescue StandardError
  nil
end

.scrim(key) ⇒ Object

Saved by the operator > registered by the app > engine default.



464
465
466
467
468
# File 'app/services/studio/email_catalog.rb', line 464

def scrim(key)
  Studio::EmailSetting.scrim_for(key) || entry(key)&.scrim
rescue StandardError
  entry(key)&.scrim
end

.scrim_percent(key) ⇒ Object



582
583
584
585
# File 'app/services/studio/email_catalog.rb', line 582

def scrim_percent(key)
  value = scrim(key) || Studio::Banner::DEFAULT_SCRIM
  (value.to_f * 100).round
end

.source(key) ⇒ Object

Where the live banner for this email actually comes from:

:app            — uploaded on this app's /admin/emails (ImageCache row
                in this app's bucket). Revertible.
:app_asset      — registered by this app, committed in its own repo.
:engine_default — the shared artwork that ships in the gem.
:none           — no image at all; the email sends bannerless.

:default USED to cover the middle two together, and the page said "Shared Studio artwork, shipped with the engine" for both — so turf-monster's own eight banners were announced as the engine's. Telling the operator the wrong provenance is the same failure this page was built to end (the page it replaced claimed "No image yet" about an email that was visibly sending one).

:app is deliberately unchanged: turf-monster's suite on main asserts it, and consumer CI runs consumers' default branch.



444
445
446
447
448
449
# File 'app/services/studio/email_catalog.rb', line 444

def source(key)
  return :app if record(key)
  return :none unless default_asset_path(key)

  entry(key)&.engine_artwork? ? :engine_default : :app_asset
end

.store(key, io:, content_type: nil) ⇒ Object

Upload bytes to this app's bucket + upsert its ImageCache row (replacing any prior object). Returns the ::ImageCache. Raises on failure after cleaning up the new object.



860
861
862
863
864
865
866
867
868
869
870
871
872
# File 'app/services/studio/email_catalog.rb', line 860

def store(key, io:, content_type: nil)
  s3_key = "email_banners/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
  Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
                    cache_control: "public, max-age=300")
  record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
  previous = record.s3_key
  record.update!(s3_key: s3_key)
  delete_object(previous) if previous.present? && previous != s3_key
  record
rescue StandardError
  delete_object(s3_key)
  raise
end

.store_logo(key, io:, content_type: nil) ⇒ Object



790
791
792
793
794
795
796
797
798
799
800
801
802
# File 'app/services/studio/email_catalog.rb', line 790

def (key, io:, content_type: nil)
  s3_key = "email_logos/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
  Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
                    cache_control: "public, max-age=300")
  record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
  previous = record.s3_key
  record.update!(s3_key: s3_key)
  delete_object(previous) if previous.present? && previous != s3_key
  record
rescue StandardError
  delete_object(s3_key)
  raise
end

.subject_for(key, name: nil) ⇒ Object

The subject line, resolved the same way and supporting the same name placeholder. A mailer calls this instead of hard-coding a string, which is what makes the field on /admin/emails real rather than decorative.



554
555
556
557
558
559
# File 'app/services/studio/email_catalog.rb', line 554

def subject_for(key, name: nil)
  template = saved(key, :subject) || entry(key)&.subject
  return nil if template.blank?

  Studio::Banner.interpolate(template, name).presence
end

.subtext(key) ⇒ Object



489
490
491
# File 'app/services/studio/email_catalog.rb', line 489

def subtext(key)
  saved(key, :subtext) || entry(key)&.subtext
end

.table_ready?Boolean

Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT trigger autoload, so it would read "undefined" for a not-yet-loaded const.

Returns:

  • (Boolean)


890
891
892
893
894
# File 'app/services/studio/email_catalog.rb', line 890

def table_ready?
  ::ImageCache.table_exists?
rescue NameError, ActiveRecord::ActiveRecordError
  false
end

.type(key) ⇒ Object



632
633
634
# File 'app/services/studio/email_catalog.rb', line 632

def type(key)
  entry(key)&.type || DEFAULT_TYPE
end

.uploaded_logo_url(key) ⇒ Object

An uploaded logo for this email, or nil to inherit.



784
785
786
787
788
# File 'app/services/studio/email_catalog.rb', line 784

def uploaded_logo_url(key)
  logo_record(key)&.url
rescue StandardError
  nil
end

.uploads_available?Boolean

Whether THIS app can accept an upload. False when the host never set Studio.s3_bucket_prefix — /admin/emails then shows inherited defaults read-only rather than 500ing on the first upload.

Returns:

  • (Boolean)


853
854
855
# File 'app/services/studio/email_catalog.rb', line 853

def uploads_available?
  Studio::S3.configured? && table_ready?
end

.url(key) ⇒ Object

THIS APP'S OWN image only — nil when nothing has been uploaded here.

This is the PRE-REGISTRY contract, kept EXACTLY: url has always meant "the admin-managed override, or nil", and callers were written to fall back themselves. turf-monster's mailer is the live example:

@banner_url = Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")

Making url resolve to the engine default would make that || dead code and silently replace turf-monster's own branded 1200x600 banner with the engine's PLACEHOLDER in real sign-in email. A method whose signature is unchanged but whose return value flips from nil to a value is not additive. So the new two-layer resolution lives in resolved_url, and every existing caller keeps the behavior it was written against until its app adopts.



713
714
715
# File 'app/services/studio/email_catalog.rb', line 713

def url(key)
  record(key)&.url
end

.variantsObject

Legacy shape — key => label. Kept because it is the API the pre-registry admin page and any host that read VARIANTS were written against.



399
400
401
# File 'app/services/studio/email_catalog.rb', line 399

def variants
  registry.transform_values(&:label)
end