Module: Studio::EmailImage
- Defined in:
- app/services/studio/email_image.rb
Overview
The transactional-email registry, and the banner image each registered email ships with.
A registered email is MOSTLY SYMBOLIC of a workflow — a key, a human label, a line of description. The only real asset is its banner image, which is why the registry lives here rather than in a model. The branded mailer resolves the live banner with .url; /admin/emails lists the registry and writes an override with .store.
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::EmailImage.register("winnings", label: "Contest winnings",
description: "Sent when a player wins a contest.")
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.
Defined Under Namespace
Classes: Entry
Constant Summary collapse
- PURPOSE =
"email_banner".freeze
- 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.png" }, { key: "email_change_confirmation", label: "Email change confirmation", description: "Confirms a new address before the change takes effect.", default_asset: "emails/email-change-confirmation.png" } ].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_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.
-
.preview_url(key) ⇒ Object
What the ADMIN PAGE previews.
-
.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) ⇒ Object
Register (or update) an email workflow.
- .registered?(key) ⇒ Boolean
- .registry ⇒ Object
-
.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 comes from: :app — this app uploaded its own (ImageCache row in its bucket) :default — the inherited engine default (gem asset) :none — no image at all; the email sends bannerless.
-
.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.
-
.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_owned?(key) ⇒ Boolean
149 |
# File 'app/services/studio/email_image.rb', line 149 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.
198 199 200 201 202 203 204 205 206 |
# File 'app/services/studio/email_image.rb', line 198 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.
212 213 214 215 216 217 218 219 |
# File 'app/services/studio/email_image.rb', line 212 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
323 324 325 326 327 |
# File 'app/services/studio/email_image.rb', line 323 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.
95 96 97 |
# File 'app/services/studio/email_image.rb', line 95 def entries registry.values end |
.entry(key) ⇒ Object
99 100 101 |
# File 'app/services/studio/email_image.rb', line 99 def entry(key) registry[key.to_s] end |
.ext_for(content_type) ⇒ Object
314 315 316 317 318 319 320 321 |
# File 'app/services/studio/email_image.rb', line 314 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
103 104 105 |
# File 'app/services/studio/email_image.rb', line 103 def keys registry.keys end |
.known?(key) ⇒ Boolean
107 108 109 |
# File 'app/services/studio/email_image.rb', line 107 def known?(key) registry.key?(key.to_s) end |
.label(key) ⇒ Object
112 113 114 |
# File 'app/services/studio/email_image.rb', line 112 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.
279 280 281 282 283 284 285 286 287 288 289 290 291 |
# File 'app/services/studio/email_image.rb', line 279 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.
305 306 307 308 309 310 |
# File 'app/services/studio/email_image.rb', line 305 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".
296 297 298 299 300 301 |
# File 'app/services/studio/email_image.rb', line 296 def mailer_protocol(, host) explicit = [:protocol].presence return explicit.to_s.sub(%r{://\z}, "") if explicit LOOPBACK_HOSTS.include?(host.downcase) ? "http" : "https" 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).
184 185 186 |
# File 'app/services/studio/email_image.rb', line 184 def preview_url(key) url(key) || default_asset_path(key) end |
.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.
190 191 192 193 194 |
# File 'app/services/studio/email_image.rb', line 190 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) ⇒ Object
Register (or update) an email workflow. Returns the key.
81 82 83 84 85 86 87 88 89 90 91 |
# File 'app/services/studio/email_image.rb', line 81 def register(key, label: nil, description: nil, default_asset: 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 ) key end |
.registered?(key) ⇒ Boolean
110 |
# File 'app/services/studio/email_image.rb', line 110 def registered?(key) = known?(key) |
.registry ⇒ Object
130 131 132 133 134 |
# File 'app/services/studio/email_image.rb', line 130 def registry @registry ||= STANDARD.each_with_object({}) do |attrs, out| out[attrs[:key]] = Entry.new(**attrs) end end |
.reset! ⇒ Object
Drops host registrations back to the standard two. For tests and for to_prepare re-registration.
124 125 126 127 128 |
# File 'app/services/studio/email_image.rb', line 124 def reset! @registry = 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.
176 177 178 |
# File 'app/services/studio/email_image.rb', line 176 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.
249 250 251 252 253 254 255 256 257 |
# File 'app/services/studio/email_image.rb', line 249 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 comes from:
:app — this app uploaded its own (ImageCache row in its bucket)
:default — the inherited engine default (gem asset)
:none — no image at all; the email sends bannerless
142 143 144 145 146 147 |
# File 'app/services/studio/email_image.rb', line 142 def source(key) return :app if record(key) return :default if default_asset_path(key) :none 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.
233 234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'app/services/studio/email_image.rb', line 233 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.
263 264 265 266 267 |
# File 'app/services/studio/email_image.rb', line 263 def table_ready? ::ImageCache.table_exists? rescue NameError, ActiveRecord::ActiveRecordError false 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.
226 227 228 |
# File 'app/services/studio/email_image.rb', line 226 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.
165 166 167 |
# File 'app/services/studio/email_image.rb', line 165 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.
118 119 120 |
# File 'app/services/studio/email_image.rb', line 118 def variants registry.transform_values(&:label) end |