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

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

Returns:

  • (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.

Returns:

  • (Boolean)


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

.variantsObject



21
22
23
# File 'app/services/studio/email_image.rb', line 21

def variants
  VARIANTS
end