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
- 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: 3.0 }, { key: "email_change_confirmation", label: "Email change confirmation", description: "Confirms a new address before the change takes effect.", default_asset: "emails/email-change-confirmation.gif", aspect_ratio: 3.0 } ].freeze
- ASPECT_RATIO =
Banners render full-bleed at 600px in a 600px card. 1200x600 is the right cut: 2:1, retina-sharp at render width, and small enough to stay out of an inbox clipping limit.
2.0- MAX_WIDTH =
1200- LOOPBACK_HOSTS =
%w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
Class Method Summary collapse
-
.app_artwork?(key) ⇒ Boolean
True when the live banner belongs to this app either way — uploaded here or committed here.
- .app_owned?(key) ⇒ Boolean
-
.default_asset_path(key) ⇒ Object
Root-relative path to the inherited default asset, or nil when the email has no default registered or the host's pipeline cannot resolve it.
-
.default_url(key) ⇒ Object
Absolute URL to the inherited default asset — what a mailer needs.
- .delete_object(key) ⇒ Object
-
.entries ⇒ Object
Every registered email, in display order: the standard two first, then the host's own in declaration order.
- .entry(key) ⇒ Object
- .ext_for(content_type) ⇒ Object
- .keys ⇒ Object
- .known?(key) ⇒ Boolean
- .label(key) ⇒ Object
-
.mailer_asset_host ⇒ Object
The origin an email's banner URL hangs off.
-
.mailer_port_suffix(options) ⇒ Object
Ports are part of the origin, and omitting one sends the reader to :443.
-
.mailer_protocol(options, host) ⇒ Object
Honor an explicit :protocol.
-
.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.
-
.preview_error(key) ⇒ Object
The reason the last preview_mail(key) returned nil, or nil if it did not fail.
-
.preview_html(key) ⇒ Object
The rendered HTML body of the preview, for the iframe.
-
.preview_mail(key) ⇒ Object
Build the sample Mail for this email, or nil.
- .preview_subject(key) ⇒ Object
-
.preview_url(key) ⇒ Object
What the ADMIN PAGE previews.
-
.previewable?(key) ⇒ Boolean
--- Preview -----------------------------------------------------------.
-
.ratio(key) ⇒ Object
This email's banner shape, falling back to the shared default.
-
.record(key) ⇒ Object
The ImageCache row holding this app's override, or nil (nothing uploaded / table not installed yet).
-
.register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil, aspect_ratio: nil) ⇒ Object
Register (or update) an email workflow.
- .registered?(key) ⇒ Boolean
-
.registry ⇒ Object
Seeded through the SAME normalization register() uses, so a standard entry is indistinguishable from a host-registered one (its
typeis a real symbol, not nil) and every reader can trust the shape. -
.reset! ⇒ Object
Drops host registrations back to the standard two.
-
.resolved_url(key) ⇒ Object
What ACTUALLY SHIPS on this email — the two-layer resolution.
-
.revert(key) ⇒ Object
Drop this app's override and fall back to the inherited default.
-
.source(key) ⇒ Object
Where the live banner for this email actually comes from:.
-
.store(key, io:, content_type: nil) ⇒ Object
Upload bytes to this app's bucket + upsert its ImageCache row (replacing any prior object).
-
.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.
- .type(key) ⇒ Object
-
.uploads_available? ⇒ Boolean
Whether THIS app can accept an upload.
-
.url(key) ⇒ Object
THIS APP'S OWN image only — nil when nothing has been uploaded here.
-
.variants ⇒ Object
Legacy shape — key => label.
Class Method Details
.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.
237 |
# File 'app/services/studio/email_catalog.rb', line 237 def app_artwork?(key) = %i[app app_asset].include?(source(key)) |
.app_owned?(key) ⇒ Boolean
242 |
# File 'app/services/studio/email_catalog.rb', line 242 def app_owned?(key) = source(key) == :app |
.default_asset_path(key) ⇒ Object
Root-relative path to the inherited default asset, or nil when the email has no default registered or the host's pipeline cannot resolve it.
364 365 366 367 368 369 370 371 372 |
# File 'app/services/studio/email_catalog.rb', line 364 def default_asset_path(key) asset = entry(key)&.default_asset return nil if asset.nil? || asset.empty? path = ActionController::Base.helpers.asset_path(asset) path.presence rescue StandardError nil 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.
378 379 380 381 382 383 384 385 |
# File 'app/services/studio/email_catalog.rb', line 378 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
489 490 491 492 493 |
# File 'app/services/studio/email_catalog.rb', line 489 def delete_object(key) Studio::S3.delete(key: key) rescue StandardError nil end |
.entries ⇒ Object
Every registered email, in display order: the standard two first, then the host's own in declaration order.
162 163 164 |
# File 'app/services/studio/email_catalog.rb', line 162 def entries registry.values end |
.entry(key) ⇒ Object
166 167 168 |
# File 'app/services/studio/email_catalog.rb', line 166 def entry(key) registry[key.to_s] end |
.ext_for(content_type) ⇒ Object
480 481 482 483 484 485 486 487 |
# File 'app/services/studio/email_catalog.rb', line 480 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" else ".png" end end |
.keys ⇒ Object
170 171 172 |
# File 'app/services/studio/email_catalog.rb', line 170 def keys registry.keys end |
.known?(key) ⇒ Boolean
174 175 176 |
# File 'app/services/studio/email_catalog.rb', line 174 def known?(key) registry.key?(key.to_s) end |
.label(key) ⇒ Object
179 180 181 |
# File 'app/services/studio/email_catalog.rb', line 179 def label(key) entry(key)&.label || key.to_s.humanize end |
.mailer_asset_host ⇒ Object
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://" 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.
445 446 447 448 449 450 451 452 453 454 455 456 457 |
# File 'app/services/studio/email_catalog.rb', line 445 def mailer_asset_host configured = Rails.application.config.action_mailer.asset_host.presence return configured if configured = ActionMailer::Base. || {} host = [:host].presence return nil if host.nil? return host if host.start_with?("http") "#{mailer_protocol(, host)}://#{host}#{mailer_port_suffix()}" 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.
471 472 473 474 475 476 |
# File 'app/services/studio/email_catalog.rb', line 471 def mailer_port_suffix() port = [: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".
462 463 464 465 466 467 |
# File 'app/services/studio/email_catalog.rb', line 462 def mailer_protocol(, host) explicit = [: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.
155 156 157 158 |
# File 'app/services/studio/email_catalog.rb', line 155 def normalize_type(type) symbol = type.to_s.strip.downcase.to_sym TYPES.include?(symbol) ? symbol : DEFAULT_TYPE 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.
275 276 277 |
# File 'app/services/studio/email_catalog.rb', line 275 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.
281 282 283 284 285 286 287 288 289 |
# File 'app/services/studio/email_catalog.rb', line 281 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.}" 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.
261 262 263 264 265 266 267 268 269 270 271 |
# File 'app/services/studio/email_catalog.rb', line 261 def preview_mail(key) callable = entry(key)&.preview return nil unless callable.respond_to?(:call) @preview_errors ||= {} @preview_errors.delete(key.to_s) (callable.call) rescue StandardError, ScriptError => e (@preview_errors ||= {})[key.to_s] = "#{e.class}: #{e.}" nil end |
.preview_subject(key) ⇒ Object
291 292 293 |
# File 'app/services/studio/email_catalog.rb', line 291 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).
350 351 352 |
# File 'app/services/studio/email_catalog.rb', line 350 def preview_url(key) url(key) || default_asset_path(key) end |
.previewable?(key) ⇒ Boolean
--- Preview -----------------------------------------------------------
246 247 248 |
# File 'app/services/studio/email_catalog.rb', line 246 def previewable?(key) entry(key)&.previewable? || false end |
.ratio(key) ⇒ Object
This email's banner shape, falling back to the shared default.
240 |
# File 'app/services/studio/email_catalog.rb', line 240 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.
356 357 358 359 360 |
# File 'app/services/studio/email_catalog.rb', line 356 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) ⇒ 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.
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'app/services/studio/email_catalog.rb', line 132 def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil, aspect_ratio: 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, aspect_ratio: aspect_ratio || existing&.aspect_ratio ) key end |
.registered?(key) ⇒ Boolean
177 |
# File 'app/services/studio/email_catalog.rb', line 177 def registered?(key) = known?(key) |
.registry ⇒ Object
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.
201 202 203 204 205 206 207 |
# File 'app/services/studio/email_catalog.rb', line 201 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]) end end |
.reset! ⇒ Object
Drops host registrations back to the standard two. For tests and for to_prepare re-registration.
191 192 193 194 195 196 |
# File 'app/services/studio/email_catalog.rb', line 191 def reset! @registry = nil @preview_errors = nil registry nil 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.
342 343 344 |
# File 'app/services/studio/email_catalog.rb', line 342 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.
415 416 417 418 419 420 421 422 423 |
# File 'app/services/studio/email_catalog.rb', line 415 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 |
.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.
228 229 230 231 232 233 |
# File 'app/services/studio/email_catalog.rb', line 228 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.
399 400 401 402 403 404 405 406 407 408 409 410 411 |
# File 'app/services/studio/email_catalog.rb', line 399 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 |
.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.
429 430 431 432 433 |
# File 'app/services/studio/email_catalog.rb', line 429 def table_ready? ::ImageCache.table_exists? rescue NameError, ActiveRecord::ActiveRecordError false end |
.type(key) ⇒ Object
250 251 252 |
# File 'app/services/studio/email_catalog.rb', line 250 def type(key) entry(key)&.type || DEFAULT_TYPE 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.
392 393 394 |
# File 'app/services/studio/email_catalog.rb', line 392 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) || ("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.
331 332 333 |
# File 'app/services/studio/email_catalog.rb', line 331 def url(key) record(key)&.url end |
.variants ⇒ Object
Legacy shape — key => label. Kept because it is the API the pre-registry admin page and any host that read VARIANTS were written against.
185 186 187 |
# File 'app/services/studio/email_catalog.rb', line 185 def variants registry.transform_values(&:label) end |