studio-engine 0.36.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,129 @@
1
+ module Studio
2
+ # /admin/emails — the standard transactional-email page every Studio app gets,
3
+ # modelled on the living style guide (/admin/style): a plain host-inherited
4
+ # controller whose view is a bare content wrapper, so it renders inside each
5
+ # host's application layout and picks up that app's navbar and theme.
6
+ #
7
+ # It lists Studio::EmailCatalog's registry — one row per registered email, each
8
+ # showing its live banner and whether that banner is the INHERITED engine
9
+ # default or an APP-OWNED override — and writes an override through the shared
10
+ # crop modal. Replaces /admin/email_images, which now redirects here.
11
+ #
12
+ # An app whose host never set Studio.s3_bucket_prefix cannot store an override.
13
+ # That is a read-only page, not an error: uploads_available? gates the write
14
+ # actions and the view explains why, so the page still shows what each email
15
+ # is currently sending.
16
+ class EmailsController < ApplicationController
17
+ before_action :require_admin
18
+ before_action :load_entry, only: %i[show raw update destroy]
19
+ before_action :require_uploads, only: %i[update destroy]
20
+
21
+ MAX_BYTES = 8.megabytes
22
+
23
+ def index
24
+ @entries = Studio::EmailCatalog.entries
25
+ @uploads_available = Studio::EmailCatalog.uploads_available?
26
+ end
27
+
28
+ # GET /admin/emails/:key — one email: its banner, its type, and a live
29
+ # preview built from the host's sample data.
30
+ def show
31
+ @entry = Studio::EmailCatalog.entry(@key)
32
+ @subject = Studio::EmailCatalog.preview_subject(@key)
33
+ @preview_error = Studio::EmailCatalog.preview_error(@key)
34
+ @uploads_available = Studio::EmailCatalog.uploads_available?
35
+ end
36
+
37
+ # GET /admin/emails/:key/raw — the rendered email itself, as the iframe
38
+ # source on #show. Layout-less on purpose: this response IS the email.
39
+ #
40
+ # A preview builder is host code run against whatever sample data this
41
+ # environment happens to hold, so it is expected to fail sometimes. It
42
+ # renders the failure as a readable page inside the iframe rather than
43
+ # 500ing, so one broken builder costs one preview, not the manager.
44
+ def raw
45
+ html = Studio::EmailCatalog.preview_html(@key)
46
+ return render(html: preview_unavailable_html.html_safe, layout: false) if html.nil?
47
+
48
+ render html: html.html_safe, layout: false
49
+ end
50
+
51
+ # PATCH /admin/emails/:key — upload/replace this app's own banner.
52
+ def update
53
+ file = params[:image]
54
+ unless valid_image?(file)
55
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
56
+ return redirect_to admin_emails_path, alert: message, status: :see_other
57
+ end
58
+
59
+ rescue_and_log do
60
+ Studio::EmailCatalog.store(@key, io: file, content_type: file.content_type)
61
+ redirect_to admin_emails_path, notice: "#{Studio::EmailCatalog.label(@key)} banner updated.", status: :see_other
62
+ end
63
+ rescue StandardError
64
+ redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
65
+ end
66
+
67
+ # DELETE /admin/emails/:key — drop this app's override and fall back to the
68
+ # inherited default.
69
+ def destroy
70
+ # rescue_and_log, like #update: destroy is a WRITE path (it drops an
71
+ # ImageCache row and deletes the S3 object behind it), and the bare rescue
72
+ # below turns any failure into a friendly alert. Without the log that
73
+ # failure is invisible — the admin sees "try again" and nothing reaches
74
+ # ErrorLog to say why.
75
+ rescue_and_log do
76
+ reverted = Studio::EmailCatalog.revert(@key)
77
+ notice = if reverted
78
+ "#{Studio::EmailCatalog.label(@key)} reverted to the inherited default."
79
+ else
80
+ "#{Studio::EmailCatalog.label(@key)} was already using the inherited default."
81
+ end
82
+ redirect_to admin_emails_path, notice: notice, status: :see_other
83
+ end
84
+ rescue StandardError
85
+ redirect_to admin_emails_path, alert: "Couldn't revert the image. Please try again.", status: :see_other
86
+ end
87
+
88
+ private
89
+
90
+ def load_entry
91
+ @key = params[:key].to_s
92
+ head :not_found unless Studio::EmailCatalog.known?(@key)
93
+ end
94
+
95
+ def require_uploads
96
+ return if Studio::EmailCatalog.uploads_available?
97
+
98
+ redirect_to admin_emails_path, status: :see_other,
99
+ alert: "This app has no object storage configured, so email images can't be changed here yet."
100
+ end
101
+
102
+ # Shown INSIDE the preview iframe when the builder is missing or raised.
103
+ # Deliberately plain inline HTML: the iframe is its own document and does not
104
+ # inherit the host app's stylesheet.
105
+ def preview_unavailable_html
106
+ reason = Studio::EmailCatalog.preview_error(@key)
107
+ message = if reason
108
+ "This email's preview builder raised:<br><code style=\"color:#b91c1c\">" \
109
+ "#{ERB::Util.html_escape(reason)}</code>"
110
+ else
111
+ "No preview is registered for this email. Add a <code>preview:</code> " \
112
+ "callable when registering it to see it rendered here."
113
+ end
114
+
115
+ <<~HTML
116
+ <!doctype html>
117
+ <html><body style="margin:0;padding:32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#334155;background:#f8fafc;">
118
+ <p style="font-size:14px;line-height:1.6;max-width:52ch;">#{message}</p>
119
+ </body></html>
120
+ HTML
121
+ end
122
+
123
+ def valid_image?(file)
124
+ file.respond_to?(:content_type) &&
125
+ file.content_type.to_s.start_with?("image/") &&
126
+ file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
127
+ end
128
+ end
129
+ end
@@ -21,7 +21,10 @@ class UserMailer < ApplicationMailer
21
21
  @app_name = Studio.app_name
22
22
  @email = email
23
23
  @magic_url = magic_link_url_for(token)
24
- @banner_url = Studio::EmailImage.url(:magic_link) # admin-managed; nil renders bannerless
24
+ # resolved_url, not url: this app's own upload if it has one, otherwise the
25
+ # engine's default banner — which is what makes a brand-new app's sign-in
26
+ # email branded on day one. nil renders bannerless.
27
+ @banner_url = Studio::EmailCatalog.resolved_url(:magic_link)
25
28
  @banner_alt = "Your #{@app_name} sign-in link"
26
29
  mail(to: email, subject: "Your #{@app_name} sign-in link")
27
30
  end
@@ -1,6 +1,6 @@
1
1
  class ImageCache < ApplicationRecord
2
2
  # Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
3
- # Studio::EmailImage stores the admin-managed email banners owner-less. Per-
3
+ # Studio::EmailCatalog stores the admin-managed email banners owner-less. Per-
4
4
  # record images (athlete/coach headshots) still set an owner; the
5
5
  # variant-uniqueness scope below keeps both shapes distinct.
6
6
  belongs_to :owner, polymorphic: true, optional: true
@@ -0,0 +1,450 @@
1
+ module Studio
2
+ # The shared email catalog: every email an app sends, what kind it is, how to
3
+ # build a live preview of it, and the banner image it ships with.
4
+ #
5
+ # Named for the prior art it absorbs. turf-monster built ::EmailCatalog +
6
+ # Admin::EmailsController first and left a note on both saying this manager
7
+ # "moves into the shared studio-engine email framework (Phase 2)". This is
8
+ # Phase 2 — so the engine takes the name, the shape (key / name / type /
9
+ # description / preview builder), and the live-preview page, and adds the
10
+ # banner-image half. turf-monster then deletes its copy instead of running two
11
+ # email pages side by side.
12
+ #
13
+ # Was Studio::EmailImage, which is now a delegating shim — see
14
+ # app/services/studio/email_image.rb. The old name outlived its meaning the
15
+ # moment an entry carried a type and a preview builder alongside its image.
16
+ #
17
+ # ## Two layers: inherited default, app-owned override
18
+ #
19
+ # .resolved_url(key) => app's own ImageCache row (its S3 bucket) # app-owned
20
+ # -> the engine's default gem asset # inherited
21
+ # -> nil # no image
22
+ #
23
+ # `.url(key)` stays the PRE-REGISTRY contract — this app's own image or nil —
24
+ # so every caller written before the registry keeps its behavior until its app
25
+ # adopts. See the note on #url; getting this wrong swaps a host's committed
26
+ # artwork for the engine placeholder in live email.
27
+ #
28
+ # Defaults RIDE THE GEM (app/assets/images/emails/*), so a brand-new app with
29
+ # an empty bucket sends good-looking email on day one and needs no cross-app S3
30
+ # permission. Uploading on an app's /admin/emails writes to THAT app's bucket
31
+ # and THAT app's ImageCache row — which is exactly "the asset now belongs to
32
+ # this app". Every app has its own bucket and its own image_caches table, so an
33
+ # override never leaks between apps.
34
+ #
35
+ # ## Registering
36
+ #
37
+ # The engine pre-registers the two every Studio app sends (STANDARD below), so
38
+ # hosts inherit them without declaring anything. A host adds its own workflows
39
+ # from an initializer, mirroring Studio::ModelPage.register:
40
+ #
41
+ # # config/initializers/studio_emails.rb
42
+ # Rails.application.config.to_prepare do
43
+ # Studio::EmailCatalog.register("winnings",
44
+ # label: "Contest winnings",
45
+ # description: "Sent when a player wins a contest.",
46
+ # type: :transactional,
47
+ # preview: -> { ContestMailer.winnings(Entry.where.not(rank: nil).first) })
48
+ # end
49
+ #
50
+ # Re-registering a key updates it in place and keeps its position, so a host
51
+ # can relabel an inherited email without reordering the page.
52
+ #
53
+ # ## Preview
54
+ #
55
+ # `preview` is a callable returning a Mail — the app builds it from whatever
56
+ # sample data it likes. It is what powers the live preview on /admin/emails/:key.
57
+ # It runs ONLY on that admin page, never in a delivery path, and every call is
58
+ # wrapped: an entry whose builder raises shows the error on the page rather
59
+ # than 500ing the manager. An entry without one still lists and still manages
60
+ # its banner; it just has nothing to preview.
61
+ module EmailCatalog
62
+ PURPOSE = "email_banner".freeze
63
+
64
+ # What an email is FOR. Transactional = sent in response to something the
65
+ # recipient did; marketing = sent because we decided to. Kept because
66
+ # turf-monster's catalog carried it and the distinction drives real policy
67
+ # (unsubscribe requirements, send-time rules, which from-address is used).
68
+ TYPES = %i[transactional marketing].freeze
69
+ DEFAULT_TYPE = :transactional
70
+
71
+ # A registered email.
72
+ # default_asset — logical asset path inside the gem (resolved through the
73
+ # host's pipeline); nil means no inherited artwork.
74
+ # type — :transactional or :marketing.
75
+ # preview — callable returning a Mail, or nil.
76
+ Entry = Struct.new(:key, :label, :description, :default_asset, :type, :preview,
77
+ keyword_init: true) do
78
+ def to_s = key
79
+ def previewable? = preview.respond_to?(:call)
80
+ # nil-safe: an Entry built directly (the STANDARD seed) may carry no type.
81
+ def marketing? = type.to_s == "marketing"
82
+ end
83
+
84
+ # The emails EVERY Studio app sends. Pre-registered, so a host inherits both
85
+ # without declaring anything.
86
+ STANDARD = [
87
+ {
88
+ key: "magic_link",
89
+ label: "Magic-link sign-in",
90
+ description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
91
+ default_asset: "emails/magic-link.png"
92
+ },
93
+ {
94
+ key: "email_change_confirmation",
95
+ label: "Email change confirmation",
96
+ description: "Confirms a new address before the change takes effect.",
97
+ default_asset: "emails/email-change-confirmation.png"
98
+ }
99
+ ].freeze
100
+
101
+ # Banners render full-bleed at 600px in a 600px card. 1200x600 is the
102
+ # right cut: 2:1, retina-sharp at render width, and small enough to stay
103
+ # out of an inbox clipping limit.
104
+ ASPECT_RATIO = 2.0
105
+ MAX_WIDTH = 1200
106
+
107
+ module_function
108
+
109
+ # --- Registry ----------------------------------------------------------
110
+
111
+ # Register (or update) an email workflow. Returns the key.
112
+ #
113
+ # Every keyword is OPTIONAL and omitting one on a re-register KEEPS the
114
+ # existing value — that is what lets a host relabel an inherited email, or
115
+ # attach a preview builder to it, without restating its artwork.
116
+ def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil)
117
+ key = key.to_s
118
+ existing = registry[key]
119
+ registry[key] = Entry.new(
120
+ key: key,
121
+ label: label || existing&.label || key.humanize,
122
+ description: description || existing&.description,
123
+ default_asset: default_asset.nil? ? existing&.default_asset : default_asset.presence,
124
+ type: normalize_type(type || existing&.type),
125
+ preview: preview || existing&.preview
126
+ )
127
+ key
128
+ end
129
+
130
+ # Unknown types fall back to :transactional rather than raising — a typo in
131
+ # an initializer must not take the host's boot down over a display label.
132
+ def normalize_type(type)
133
+ symbol = type.to_s.strip.downcase.to_sym
134
+ TYPES.include?(symbol) ? symbol : DEFAULT_TYPE
135
+ end
136
+
137
+ # Every registered email, in display order: the standard two first, then the
138
+ # host's own in declaration order.
139
+ def entries
140
+ registry.values
141
+ end
142
+
143
+ def entry(key)
144
+ registry[key.to_s]
145
+ end
146
+
147
+ def keys
148
+ registry.keys
149
+ end
150
+
151
+ def known?(key)
152
+ registry.key?(key.to_s)
153
+ end
154
+ def registered?(key) = known?(key)
155
+
156
+ def label(key)
157
+ entry(key)&.label || key.to_s.humanize
158
+ end
159
+
160
+ # Legacy shape — key => label. Kept because it is the API the pre-registry
161
+ # admin page and any host that read VARIANTS were written against.
162
+ def variants
163
+ registry.transform_values(&:label)
164
+ end
165
+
166
+ # Drops host registrations back to the standard two. For tests and for
167
+ # to_prepare re-registration.
168
+ def reset!
169
+ @registry = nil
170
+ @preview_errors = nil
171
+ registry
172
+ nil
173
+ end
174
+
175
+ # Seeded through the SAME normalization register() uses, so a standard entry
176
+ # is indistinguishable from a host-registered one (its `type` is a real
177
+ # symbol, not nil) and every reader can trust the shape.
178
+ def registry
179
+ @registry ||= STANDARD.each_with_object({}) do |attrs, out|
180
+ out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]), preview: attrs[:preview])
181
+ end
182
+ end
183
+
184
+ # --- Resolution --------------------------------------------------------
185
+
186
+ # Where the live banner for this email comes from:
187
+ # :app — this app uploaded its own (ImageCache row in its bucket)
188
+ # :default — the inherited engine default (gem asset)
189
+ # :none — no image at all; the email sends bannerless
190
+ def source(key)
191
+ return :app if record(key)
192
+ return :default if default_asset_path(key)
193
+
194
+ :none
195
+ end
196
+
197
+ def app_owned?(key) = source(key) == :app
198
+
199
+ # --- Preview -----------------------------------------------------------
200
+
201
+ def previewable?(key)
202
+ entry(key)&.previewable? || false
203
+ end
204
+
205
+ def type(key)
206
+ entry(key)&.type || DEFAULT_TYPE
207
+ end
208
+
209
+ # Build the sample Mail for this email, or nil.
210
+ #
211
+ # NEVER raises. A preview builder is host code running against whatever
212
+ # sample data happens to be in this environment — an empty table, a fixture
213
+ # that moved, a mailer whose signature changed. Any of those must show up as
214
+ # a message ON the preview page, not as a 500 that takes the whole email
215
+ # manager down with it. Returns nil; ask #preview_error for the reason.
216
+ def preview_mail(key)
217
+ callable = entry(key)&.preview
218
+ return nil unless callable.respond_to?(:call)
219
+
220
+ @preview_errors ||= {}
221
+ @preview_errors.delete(key.to_s)
222
+ force_message(callable.call)
223
+ rescue StandardError, ScriptError => e
224
+ (@preview_errors ||= {})[key.to_s] = "#{e.class}: #{e.message}"
225
+ nil
226
+ end
227
+
228
+ # The reason the last preview_mail(key) returned nil, or nil if it did not
229
+ # fail. Set by preview_mail; read by the page so it can say WHY.
230
+ def preview_error(key)
231
+ (@preview_errors ||= {})[key.to_s]
232
+ end
233
+
234
+ # The rendered HTML body of the preview, for the iframe. nil when the email
235
+ # has no builder or the builder failed.
236
+ def preview_html(key)
237
+ mail = preview_mail(key)
238
+ return nil if mail.nil?
239
+
240
+ (mail.html_part&.body || mail.body).to_s
241
+ rescue StandardError => e
242
+ (@preview_errors ||= {})[key.to_s] = "#{e.class}: #{e.message}"
243
+ nil
244
+ end
245
+
246
+ def preview_subject(key)
247
+ preview_mail(key)&.subject
248
+ end
249
+
250
+ # Force a builder's return value to a REAL mail, here, inside preview_mail's
251
+ # rescue.
252
+ #
253
+ # The documented idiom — `preview: -> { UserMailer.magic_link(user, token) }`
254
+ # — does not return a Mail. It returns an ActionMailer::MessageDelivery, a
255
+ # LAZY proxy: the mailer action has not run yet, and the first call to
256
+ # `subject` / `html_part` is what finally runs it. So without this, a builder
257
+ # that fails takes `callable.call` cleanly, records NO error, and then blows
258
+ # up later at `preview_subject` — outside every rescue, straight into the
259
+ # host's error handler, taking the whole page down. That is the exact failure
260
+ # this class exists to prevent, so the forcing belongs at the same layer as
261
+ # the rescue, not at each call site.
262
+ #
263
+ # Duck-typed rather than `is_a?(ActionMailer::MessageDelivery)`: it also
264
+ # covers Parameterized::MessageDelivery and any host's own lazy wrapper.
265
+ # Mail::Message does NOT respond to `message`, so a builder that already
266
+ # returns a real Mail passes straight through.
267
+ def force_message(result)
268
+ result.respond_to?(:message) ? result.message : result
269
+ end
270
+ private_class_method :force_message
271
+
272
+ # THIS APP'S OWN image only — nil when nothing has been uploaded here.
273
+ #
274
+ # This is the PRE-REGISTRY contract, kept EXACTLY: `url` has always meant
275
+ # "the admin-managed override, or nil", and callers were written to fall back
276
+ # themselves. turf-monster's mailer is the live example:
277
+ #
278
+ # @banner_url = Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")
279
+ #
280
+ # Making `url` resolve to the engine default would make that `||` dead code
281
+ # and silently replace turf-monster's own branded 1200x600 banner with the
282
+ # engine's PLACEHOLDER in real sign-in email. A method whose signature is
283
+ # unchanged but whose return value flips from nil to a value is not additive.
284
+ # So the new two-layer resolution lives in resolved_url, and every existing
285
+ # caller keeps the behavior it was written against until its app adopts.
286
+ def url(key)
287
+ record(key)&.url
288
+ end
289
+
290
+ # What ACTUALLY SHIPS on this email — the two-layer resolution. Absolute, so
291
+ # it resolves from an inbox. App-owned override first, then the inherited
292
+ # engine default, then nil (the mailer renders bannerless).
293
+ #
294
+ # This is what a mailer should call once its app has adopted the registry.
295
+ # The engine's own UserMailer already does, which is what gives an app with
296
+ # an empty bucket branded email on day one.
297
+ def resolved_url(key)
298
+ url(key) || default_url(key)
299
+ end
300
+
301
+ # What the ADMIN PAGE previews. Same two layers as resolved_url, but a
302
+ # default stays a root-relative asset path so it renders correctly on
303
+ # whatever host and port this app is being viewed on (an absolute mailer
304
+ # asset_host is set for the inbox, not for a browser on localhost:3042).
305
+ def preview_url(key)
306
+ url(key) || default_asset_path(key)
307
+ end
308
+
309
+ # The ImageCache row holding this app's override, or nil (nothing uploaded /
310
+ # table not installed yet). Nil-safe so the mailer renders before any upload.
311
+ def record(key)
312
+ return nil unless table_ready?
313
+
314
+ ::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
315
+ end
316
+
317
+ # Root-relative path to the inherited default asset, or nil when the email
318
+ # has no default registered or the host's pipeline cannot resolve it.
319
+ def default_asset_path(key)
320
+ asset = entry(key)&.default_asset
321
+ return nil if asset.nil? || asset.empty?
322
+
323
+ path = ActionController::Base.helpers.asset_path(asset)
324
+ path.presence
325
+ rescue StandardError
326
+ nil
327
+ end
328
+
329
+ # Absolute URL to the inherited default asset — what a mailer needs. Uses
330
+ # action_mailer.asset_host (set per env), falling back to the mailer's
331
+ # default_url_options host. Returns the bare path if neither is configured,
332
+ # which still renders in the local inbox preview.
333
+ def default_url(key)
334
+ path = default_asset_path(key)
335
+ return nil if path.nil?
336
+ return path if path.start_with?("http")
337
+
338
+ host = mailer_asset_host
339
+ host ? "#{host}#{path}" : path
340
+ end
341
+
342
+ # --- Upload ------------------------------------------------------------
343
+
344
+ # Whether THIS app can accept an upload. False when the host never set
345
+ # Studio.s3_bucket_prefix — /admin/emails then shows inherited defaults
346
+ # read-only rather than 500ing on the first upload.
347
+ def uploads_available?
348
+ Studio::S3.configured? && table_ready?
349
+ end
350
+
351
+ # Upload bytes to this app's bucket + upsert its ImageCache row (replacing
352
+ # any prior object). Returns the ::ImageCache. Raises on failure after
353
+ # cleaning up the new object.
354
+ def store(key, io:, content_type: nil)
355
+ s3_key = "email_banners/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
356
+ Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
357
+ cache_control: "public, max-age=300")
358
+ record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
359
+ previous = record.s3_key
360
+ record.update!(s3_key: s3_key)
361
+ delete_object(previous) if previous.present? && previous != s3_key
362
+ record
363
+ rescue StandardError
364
+ delete_object(s3_key)
365
+ raise
366
+ end
367
+
368
+ # Drop this app's override and fall back to the inherited default. Returns
369
+ # true when a row was removed.
370
+ def revert(key)
371
+ row = record(key)
372
+ return false if row.nil?
373
+
374
+ previous = row.s3_key
375
+ row.destroy!
376
+ delete_object(previous) if previous.present?
377
+ true
378
+ end
379
+
380
+ # --- Internals ---------------------------------------------------------
381
+
382
+ # Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT
383
+ # trigger autoload, so it would read "undefined" for a not-yet-loaded const.
384
+ def table_ready?
385
+ ::ImageCache.table_exists?
386
+ rescue NameError, ActiveRecord::ActiveRecordError
387
+ false
388
+ end
389
+
390
+ # The origin an email's banner URL hangs off. action_mailer.asset_host when
391
+ # the host sets one (turf-monster does, per env); otherwise built from the
392
+ # mailer's default_url_options.
393
+ #
394
+ # That fallback has to reconstruct a real origin, not just the hostname.
395
+ # default_url_options is routinely {host: "localhost", port: 3001} — taking
396
+ # :host alone and prefixing "https://" yields https://localhost, which is the
397
+ # wrong scheme AND the wrong port, and the banner comes back
398
+ # ERR_CONNECTION_REFUSED. Caught by opening the preview page on a worktree
399
+ # stack; every dev/QA preview took that path.
400
+ def mailer_asset_host
401
+ configured = Rails.application.config.action_mailer.asset_host.presence
402
+ return configured if configured
403
+
404
+ options = ActionMailer::Base.default_url_options || {}
405
+ host = options[:host].presence
406
+ return nil if host.nil?
407
+ return host if host.start_with?("http")
408
+
409
+ "#{mailer_protocol(options, host)}://#{host}#{mailer_port_suffix(options)}"
410
+ rescue StandardError
411
+ nil
412
+ end
413
+
414
+ # Honor an explicit :protocol. Otherwise https — EXCEPT on loopback, which is
415
+ # a dev stack with no TLS. Defaulting the other way would downgrade every
416
+ # production app that sets only {host: "mcritchie.studio"}.
417
+ def mailer_protocol(options, host)
418
+ explicit = options[:protocol].presence
419
+ return explicit.to_s.sub(%r{://\z}, "") if explicit
420
+
421
+ LOOPBACK_HOSTS.include?(host.downcase) ? "http" : "https"
422
+ end
423
+
424
+ # Ports are part of the origin, and omitting one sends the reader to :443.
425
+ # The scheme defaults are left off so a normal URL stays normal.
426
+ def mailer_port_suffix(options)
427
+ port = options[:port]
428
+ return "" if port.blank? || [80, 443].include?(port.to_i)
429
+
430
+ ":#{port}"
431
+ end
432
+
433
+ LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
434
+
435
+ def ext_for(content_type)
436
+ case content_type.to_s
437
+ when %r{png} then ".png"
438
+ when %r{jpe?g} then ".jpg"
439
+ when %r{webp} then ".webp"
440
+ else ".png"
441
+ end
442
+ end
443
+
444
+ def delete_object(key)
445
+ Studio::S3.delete(key: key)
446
+ rescue StandardError
447
+ nil
448
+ end
449
+ end
450
+ end