Module: Studio::EmailImage
- Defined in:
- app/services/studio/email_image.rb
Overview
Admin-managed banner images for transactional emails. One image per “variant” (the email type), uploaded to S3 with a stable PUBLIC url via Studio::S3 and tracked by an owner-less ImageCache row (purpose “email_banner”). The branded mailer resolves the current banner with .url; the admin email-image page writes it with .store.
VARIANTS is the registry — magic_link now; adding an entry is all it takes to admin-manage another email’s header image (the “extensible” part of the magic-link-now-extensible scope).
Constant Summary collapse
- PURPOSE =
"email_banner".freeze
- VARIANTS =
variant => human label, in admin display order.
{ "magic_link" => "Magic-link sign-in" }.freeze
Class Method Summary collapse
- .delete_object(key) ⇒ Object
- .ext_for(content_type) ⇒ Object
- .known?(variant) ⇒ Boolean
- .label(variant) ⇒ Object
-
.record(variant) ⇒ Object
The ImageCache row for this variant, or nil (no banner uploaded / table not installed yet).
-
.store(variant, io:, content_type: nil) ⇒ Object
Upload bytes to S3 + upsert the 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.
-
.url(variant) ⇒ Object
Permanent public S3 url for the current banner, or nil.
- .variants ⇒ Object
Class Method Details
.delete_object(key) ⇒ Object
79 80 81 82 83 |
# File 'app/services/studio/email_image.rb', line 79 def delete_object(key) Studio::S3.delete(key: key) rescue StandardError nil end |
.ext_for(content_type) ⇒ Object
70 71 72 73 74 75 76 77 |
# File 'app/services/studio/email_image.rb', line 70 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 |
.known?(variant) ⇒ Boolean
29 30 31 |
# File 'app/services/studio/email_image.rb', line 29 def known?(variant) VARIANTS.key?(variant.to_s) end |
.label(variant) ⇒ Object
25 26 27 |
# File 'app/services/studio/email_image.rb', line 25 def label(variant) VARIANTS[variant.to_s] || variant.to_s.humanize end |
.record(variant) ⇒ Object
The ImageCache row for this variant, or nil (no banner uploaded / table not installed yet). Nil-safe so the mailer renders bannerless before any upload.
35 36 37 38 39 |
# File 'app/services/studio/email_image.rb', line 35 def record(variant) return nil unless table_ready? ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: variant.to_s) end |
.store(variant, io:, content_type: nil) ⇒ Object
Upload bytes to S3 + upsert the ImageCache row (replacing any prior object). Returns the ::ImageCache. Raises on failure after cleaning up the new object.
48 49 50 51 52 53 54 55 56 57 58 59 60 |
# File 'app/services/studio/email_image.rb', line 48 def store(variant, io:, content_type: nil) key = "email_banners/#{variant}-#{SecureRandom.hex(4)}#{ext_for(content_type)}" Studio::S3.upload(key: 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: variant.to_s) previous = record.s3_key record.update!(s3_key: key) delete_object(previous) if previous.present? && previous != key record rescue StandardError delete_object(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.
64 65 66 67 68 |
# File 'app/services/studio/email_image.rb', line 64 def table_ready? ::ImageCache.table_exists? rescue NameError, ActiveRecord::ActiveRecordError false end |
.url(variant) ⇒ Object
Permanent public S3 url for the current banner, or nil.
42 43 44 |
# File 'app/services/studio/email_image.rb', line 42 def url(variant) record(variant)&.url end |
.variants ⇒ Object
21 22 23 |
# File 'app/services/studio/email_image.rb', line 21 def variants VARIANTS end |