studio-engine 0.36.0 → 0.37.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8c692fc135af1ba83c08b27398c875bc547ae07bbcc492540ffd83250fccf680
4
- data.tar.gz: 891c561fe2bef1386137af4fbbc22547f38613efe8bd373bf6ec31894086feef
3
+ metadata.gz: dc5fc673a7b9d072c93f482c90919c0f26836bd5e90194691ca30b5beb7c22d4
4
+ data.tar.gz: 9cbedec8a84b9619061217f90bceadbd51485e691351233bb67af6f03231b76e
5
5
  SHA512:
6
- metadata.gz: 9cb8bb03d5adeec99336b5fab4d28b29c57be8686ca2afef58932ce866c321fe44f6ab0123609e6845151f938ab28790685bda6b9b21815ef3a3594d6c589921
7
- data.tar.gz: 2c3be4aadf2a88d7838dab26b27c4a22604268138bba818f0b82890774f7bc5b81424d53a645d3fcc5ebf59a8f10197a9b37bd9cd44246c988d8dca3dfd2cd62
6
+ metadata.gz: '088bca3db79b1ad97d52bf2478aa08518a6139e73c700cc63d30903c1d31e4fddb391213c2061cd0711d0d5656a7115e53dd10817b094271c459543133e36ea7'
7
+ data.tar.gz: 96eb836f896ed9a12dd8c71a308178aa112387bc40f266323dad82db726df9698a4881ff4450c7fa7ce9f42d61cf1bcab9ca80a88940f243891e1cfe0307f3d0
data/CHANGELOG.md CHANGED
@@ -2,8 +2,161 @@
2
2
 
3
3
  The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html) — `MAJOR.MINOR.PATCH`. Consumer Rails apps install the released RubyGems package with `gem "studio-engine", "~> 0.6"`; bumping the gem version and updating consumer lockfiles is a release.
4
4
 
5
+ ## 0.37.0 — 2026-08-10
6
+
7
+ ### Added
8
+ - Standard transactional email primitive with a registry-backed catalog, and the
9
+ email preview folded into that registry.
10
+
11
+ ### Fixed
12
+ - The crop confirm is guarded by owner, so one confirmed crop can no longer fan out
13
+ and overwrite every row's banner on /admin/emails.
14
+
5
15
  ## 0.36.0 — 2026-08-10
6
16
 
17
+ **Transactional emails become an engine primitive.** Every consuming app now
18
+ ships working, branded transactional email the moment it boots, and can grow its
19
+ own workflows and artwork. Additive — no host change is required to take this
20
+ release.
21
+
22
+ **`Studio::EmailImage` is now a registry.** It was a one-entry `VARIANTS` hash;
23
+ it is now a host-declared registry following the `Studio::ModelPage.register`
24
+ precedent. A registered email is mostly symbolic of a workflow — a key, a label,
25
+ a description — and its only real asset is its banner image.
26
+
27
+ - The engine **pre-registers the two every Studio app sends**, `magic_link` and
28
+ `email_change_confirmation`, so a host inherits both without declaring
29
+ anything.
30
+ - A host registers its own from an initializer:
31
+ `Studio::EmailImage.register("winnings", label: "Contest winnings")`.
32
+ Re-registering an inherited key **updates it in place**, keeping its page
33
+ position and its inherited artwork.
34
+ - `variants`, `label`, `known?`, `record`, `url`, and `store` keep their old
35
+ shapes, so `Studio::EmailImage.url(:magic_link)` — the only external caller
36
+ today — is untouched.
37
+
38
+ **Inheritance with per-app override.** Resolution is now two-layered:
39
+
40
+ ```
41
+ resolved_url(:magic_link) -> this app's ImageCache row (its own S3 bucket) app-owned
42
+ -> the engine's default gem asset inherited
43
+ -> nil bannerless
44
+ ```
45
+
46
+ `resolved_url`, **not** `url`. `url` deliberately keeps its pre-registry meaning —
47
+ this app's own image, or nil — so a caller written before the registry keeps the
48
+ behavior it was built against. A mailer that falls back itself
49
+ (`url(...) || own_banner`) must stay on `url`; switching it to `resolved_url`
50
+ would make that fallback dead code and replace the app's own artwork with the
51
+ engine default.
52
+
53
+ Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
54
+ an empty bucket sends good-looking email on day one with no cross-app S3
55
+ permission. Uploading writes to that app's own bucket and its own `image_caches`
56
+ row — which is exactly "the asset now belongs to this app". Placeholder artwork
57
+ ships for both standard emails; real artwork lands in a later release.
58
+
59
+ **New: `/admin/emails`**, modelled on the living style guide — one shared engine
60
+ page, admin-gated, rendering inside each host's layout.
61
+
62
+ **It is OPT-IN, and must be.** Add to `config/initializers/studio.rb`:
63
+
64
+ ```ruby
65
+ config.draw_admin_emails_routes = true
66
+ ```
67
+
68
+ `turf-monster` already owns `/admin/emails` (its `EmailCatalog` manager:
69
+ `namespace :admin { get "emails", as: :emails }`) and therefore both helper
70
+ names, `admin_emails_path` and `admin_email_path`. Drawing the engine's page
71
+ unconditionally raises `ArgumentError: Invalid route name, already in use:
72
+ 'admin_emails'` *while turf-monster's own routes are loading*, which takes down
73
+ its **entire** route set — every `admin_*_path` in the app goes undefined. A host
74
+ cannot opt out of a failure that happens before its config is read, so the
75
+ default is off until no consumer owns the name.
76
+
77
+ The gate covers only the **page**. The registry and the inherited-default
78
+ resolution are always on, and the engine's `UserMailer` already calls
79
+ `resolved_url`, so an app sends branded email whether or not it draws the page.
80
+
81
+ **`/admin/email_images` is deprecated, not deleted.** It still renders, and its
82
+ `admin_email_images_path` / `admin_email_image_path` helpers still resolve —
83
+ `mcritchie-studio` and `turf-monster` both have tests on `main` that drive it,
84
+ and consumer CI runs each consumer's default branch. A later engine minor deletes
85
+ the controller, view, and routes once no consumer references them. In the
86
+ meantime the old page had its own reporting bug fixed too: it now reads
87
+ `preview_url`, so it no longer claims "No image yet" about an email that is
88
+ visibly sending one.
89
+
90
+ - The table is the primary view: **name + live image on every row**, so an email
91
+ is identifiable at a glance.
92
+ - Each row says whether the live image is the **inherited default** or
93
+ **app-owned**, and an app-owned image can be reverted back to the default.
94
+ - Uploading goes through the **standard crop modal**, not a bare file input.
95
+
96
+ **Fix — the page misreported what was actually shipping.** It read only the S3
97
+ override, so it announced *"No image yet — emails send without a banner until you
98
+ upload one"* for an email that was visibly sending a banner from a committed repo
99
+ asset. `current_url` now resolves what really ships, and the copy distinguishes
100
+ "inherited default" from "no image at all".
101
+
102
+ **New: `Studio.s3_key_prefix`** — an optional key namespace inside the bucket, so
103
+ a satellite app can share an already-provisioned bucket instead of standing up
104
+ its own pair:
105
+
106
+ ```ruby
107
+ config.s3_bucket_prefix = "mcritchie-studio"
108
+ config.s3_key_prefix = "mcritchie-industries/"
109
+ # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
110
+ ```
111
+
112
+ `Studio::S3` applies it to `upload`, `download`, `url`, `signed_url`, `exists?`,
113
+ `delete`, and `list` — callers keep passing logical keys and never see it, and
114
+ `list` strips it back off so a result feeds straight into `download`/`url`.
115
+ **Unset by default, so every already-shipped app's keys are byte-identical.**
116
+ Also new: `Studio::S3.configured?`, for callers that must degrade rather than
117
+ rescue `NotConfigured`.
118
+
119
+ **Honest degradation.** An app whose host never set `s3_bucket_prefix` renders
120
+ `/admin/emails` **read-only** — showing the inherited defaults it is genuinely
121
+ sending, naming the one setting that turns uploads on — instead of 500ing on the
122
+ first upload.
123
+
124
+ **New partial: `studio/modals/scoped_host`** — a self-contained modal host on its
125
+ own Alpine store, for a page that must bring its own modals. `/admin/emails` uses
126
+ it (`store: "emailModals"`) to ship its crop + saving modals, so the page works
127
+ identically in an app that renders a shared modal host and one that renders none
128
+ at all. `imageUploadHost` and `submitFormWithProgress` gained a matching `store`
129
+ option (default `"modals"`, so existing call sites are unchanged); the crop-photo
130
+ and saving partials already had one.
131
+
132
+ It is a **separate partial rather than a local on `studio/modals/_host`** for a
133
+ reason worth knowing: this is a non-isolated engine, so an app view at the same
134
+ path shadows the engine's — and `mcritchie-studio` and `turf-monster` both ship
135
+ their own `app/views/studio/modals/_host.html.erb`. A page rendering
136
+ `studio/modals/host` in those apps silently gets the app's fork, which knows
137
+ nothing of a `store:` local, so the page-scoped store is never registered and
138
+ every modal trigger on the page does nothing. `scoped_host` is unforked in every
139
+ app. The living style guide's hand-rolled `dsModals` host can rebase onto it.
140
+
141
+ Register modals with `current()?.id`, not `current().id` — the outer template
142
+ unmounts one tick after the stack empties, so a bare `.id` throws on close.
143
+
144
+ **Compatibility.** `Studio::EmailImage.url(key)` keeps its **pre-registry
145
+ contract** — this app's own uploaded image, or `nil`. The two-layer resolution
146
+ lives in the new `resolved_url(key)`. This matters: `turf-monster`'s mailer is
147
+ `Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`,
148
+ and making `url` resolve to the engine default would have turned that `||` into
149
+ dead code and silently replaced turf-monster's committed branded banner with the
150
+ engine's **placeholder** in live sign-in email. A method whose signature is
151
+ unchanged but whose return value flips from `nil` to a value is not additive.
152
+
153
+ **Consumer note.** Nothing here is required to upgrade. To adopt: set
154
+ `config.draw_admin_emails_routes = true`, link `admin_emails_path` from the app's
155
+ admin sidebar, register any extra workflows, switch mailers from `url` to
156
+ `resolved_url` where you want the inherited default, and drop any local
157
+ `branded_mailer.html.erb` fork — the engine's copy is app-name-aware through
158
+ `Studio.app_name`.
159
+
7
160
  **The local-review link now signs the reviewer in as someone who can SEE the
8
161
  page.** `/_studio/local_review` — the local half of the task board's WAITING
9
162
  APPROVAL button — provisions the account before it mints, at the new
data/README.md CHANGED
@@ -24,6 +24,7 @@ Then `bundle install`. The current release is **v0.6.1**; see [`CHANGELOG.md`](.
24
24
  - **Operator tooling**: Shared `studio/banners/environment` banner with Dev Mode + email connector controls, `studio/banners/impersonation`, and an opt-in `Studio::Impersonation` concern for Act As session conventions.
25
25
  - **Sluggable concern**: `before_save :set_slug` with `to_param` for human-readable URLs
26
26
  - **ThemeSetting model**: Per-app DB overrides with fallback to config defaults
27
+ - **Transactional emails**: `Studio::EmailImage` registry + the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
27
28
 
28
29
  ## Configuration
29
30
 
@@ -195,6 +196,39 @@ the block:
195
196
  <% end %>
196
197
  ```
197
198
 
199
+ **Page-scoped hosts — `studio/modals/scoped_host`.** When a page must bring its
200
+ own modals (because not every consuming app renders a shared host, and the ones
201
+ that do register their own modal set), render a second host on its own Alpine
202
+ store:
203
+
204
+ ```erb
205
+ <%= render "studio/modals/scoped_host", store: "emailModals" do %>
206
+ <template x-if="$store.emailModals.current()?.id === 'crop-photo'">
207
+ <%= render "studio/modals/crop_photo", store: "emailModals" %>
208
+ </template>
209
+ <% end %>
210
+ ```
211
+
212
+ `studio/modals/_crop_photo` and `_saving` take the same `store:` local, and
213
+ `imageUploadHost({ store: "emailModals", ... })` /
214
+ `submitFormWithProgress(form, { store: "emailModals" })` route through it — all
215
+ default to `"modals"`, so existing call sites are unchanged. The engine's
216
+ `/admin/emails` is the live example.
217
+
218
+ Two things that will bite you:
219
+
220
+ - **Render `scoped_host`, not `host`.** This is a non-isolated engine, so an app
221
+ view at the same path shadows the engine's — and `mcritchie-studio` and
222
+ `turf-monster` both ship their own `app/views/studio/modals/_host.html.erb`.
223
+ A page rendering `studio/modals/host` in those apps silently gets the app's
224
+ fork. `scoped_host` is unforked everywhere.
225
+ - **Guard registrations with `current()?.id`.** The outer template unmounts one
226
+ tick *after* the stack empties, so a bare `.id` throws on every close.
227
+
228
+ The scoped host takes its animations from `engine-motion.css` rather than an
229
+ inline copy, so a consumer bundling that layer gets the same spring as the shared
230
+ host.
231
+
198
232
  Store API (`Alpine.store('modals')`):
199
233
 
200
234
  | Call | Behavior |
@@ -268,6 +302,129 @@ The legacy string locals (`balance_html`, `extra_icons_html`, `div2_html` —
268
302
  pre-rendered HTML injected via `raw`) are deprecated but still honored when the
269
303
  matching slot is absent, so existing call sites render unchanged.
270
304
 
305
+ ## Transactional emails
306
+
307
+ Every consuming app can render the same admin page — **`/admin/emails`** —
308
+ listing each transactional email it sends with the banner riding at the top of
309
+ that email, and whether that banner is the **inherited default** or an
310
+ **app-owned override**. Supersedes the old `/admin/email_images`, which is
311
+ deprecated but still renders for one release.
312
+
313
+ **The page is opt-in:**
314
+
315
+ ```ruby
316
+ # config/initializers/studio.rb
317
+ config.draw_admin_emails_routes = true
318
+ ```
319
+
320
+ Off by default because `turf-monster` already owns `/admin/emails` and both of
321
+ its helper names; drawing them there raises at route-load and kills every route
322
+ in the app. The gate covers only the page — the registry and the inherited
323
+ defaults are always on.
324
+
325
+ ### The registry
326
+
327
+ A registered email is mostly symbolic of a workflow — a key, a label, a
328
+ description. The only real asset is its image. The engine pre-registers the two
329
+ every Studio app sends, so a new app inherits both without declaring anything:
330
+
331
+ | Key | Label |
332
+ |-----|-------|
333
+ | `magic_link` | Magic-link sign-in |
334
+ | `email_change_confirmation` | Email change confirmation |
335
+
336
+ A host adds its own workflows from an initializer, mirroring
337
+ `Studio::ModelPage.register`:
338
+
339
+ ```ruby
340
+ # config/initializers/studio_emails.rb
341
+ Rails.application.config.to_prepare do
342
+ Studio::EmailImage.register("winnings", label: "Contest winnings",
343
+ description: "Sent when a player wins a contest.")
344
+ Studio::EmailImage.register("wallet_export", label: "Wallet export")
345
+ end
346
+ ```
347
+
348
+ Re-registering an inherited key updates it in place and keeps its position, so
349
+ relabeling `magic_link` does not reorder the page or drop its default artwork.
350
+
351
+ ### Resolution — inherit, then own
352
+
353
+ ```
354
+ Studio::EmailImage.resolved_url(:magic_link)
355
+ 1. this app's ImageCache row (its own S3 bucket) -> app-owned override
356
+ 2. the engine's default gem asset -> inherited default
357
+ 3. nil -> sends bannerless
358
+ ```
359
+
360
+ Note the method: **`resolved_url` walks all three layers; `url` returns only
361
+ layer 1** (this app's own image, or nil). That split is deliberate — it keeps
362
+ every caller written before the registry behaving exactly as it did. A mailer
363
+ that already falls back on its own, `url(:magic_link) || own_banner`, must stay
364
+ on `url`; moving it to `resolved_url` makes that fallback unreachable and swaps
365
+ the app's committed artwork for the engine's default.
366
+
367
+ Defaults **ride the gem** (`app/assets/images/emails/*`), so a brand-new app with
368
+ an empty bucket sends branded email on day one and needs no cross-app S3
369
+ permission. Uploading on an app's `/admin/emails` writes to **that** app's bucket
370
+ and **that** app's `image_caches` row — which is exactly "the asset now belongs
371
+ to this app". Every app has its own bucket and table, so an override never leaks
372
+ between apps.
373
+
374
+ Three accessors, and picking the wrong one changes what real people receive:
375
+
376
+ | Call | Returns | Use for |
377
+ |---|---|---|
378
+ | `url(key)` | this app's **own** image, or `nil` | the pre-registry contract; a host doing its own `\|\| fallback` |
379
+ | `resolved_url(key)` | own → inherited default → `nil`, **absolute** | mailers |
380
+ | `preview_url(key)` | own → inherited default (root-relative) | the admin page |
381
+
382
+ `url` deliberately does **not** resolve to the default. `turf-monster`'s mailer
383
+ reads `Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")`
384
+ — making `url` resolve would turn that `||` into dead code and swap its committed
385
+ branded banner for the engine placeholder in live email.
386
+
387
+ ```ruby
388
+ class UserMailer < ApplicationMailer
389
+ layout "branded_mailer"
390
+
391
+ def magic_link(email, token)
392
+ @banner_url = Studio::EmailImage.resolved_url(:magic_link) # nil renders bannerless
393
+ # ...
394
+ end
395
+ end
396
+ ```
397
+
398
+ Do **not** fork `layouts/branded_mailer.html.erb` — the engine's copy is
399
+ app-name-aware through `Studio.app_name`.
400
+
401
+ ### Sharing a bucket — `s3_key_prefix`
402
+
403
+ Uploads need `Studio.s3_bucket_prefix`. A satellite app that has no bucket of its
404
+ own can share an existing one under its own key namespace:
405
+
406
+ ```ruby
407
+ config.s3_bucket_prefix = "mcritchie-studio"
408
+ config.s3_key_prefix = "mcritchie-industries/"
409
+ # -> s3://mcritchie-studio-dev/mcritchie-industries/email_banners/...
410
+ ```
411
+
412
+ `Studio::S3` applies the prefix to every operation, so callers keep passing
413
+ logical keys and never see it. Unset (the default) leaves keys byte-identical to
414
+ what every already-shipped app wrote.
415
+
416
+ An app with **no** bucket configured does not error — `/admin/emails` renders
417
+ read-only, showing the inherited defaults it is genuinely sending and naming the
418
+ one setting that turns uploads on.
419
+
420
+ ### Linking it
421
+
422
+ Add it to the app's admin sidebar section:
423
+
424
+ ```ruby
425
+ { label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
426
+ ```
427
+
271
428
  ## Overriding Views
272
429
 
273
430
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -1,7 +1,22 @@
1
1
  module Studio
2
- # Admin page to manage the banner image on transactional emails (Studio::
3
- # EmailImage). One managed variant today (magic_link); the registry is the
4
- # extension point. Shared by every app surfaced from each app's admin hub.
2
+ # DEPRECATED superseded by Studio::EmailsController (/admin/emails), which
3
+ # shows every registered email with its live image, says whether that image is
4
+ # the inherited default or app-owned, and uploads through the crop modal.
5
+ #
6
+ # Kept alive for ONE release on purpose, not by neglect. consumer-ci.yml checks
7
+ # out each consumer repo with no `ref:` — i.e. its DEFAULT BRANCH — and runs
8
+ # that suite against the engine PR. mcritchie-studio and turf-monster both have
9
+ # tests on `main` that drive this page and its admin_email_image_path helper,
10
+ # so deleting it here would redden their lanes from the moment the PR opens,
11
+ # and nothing inside the engine PR could fix it: the consumer PRs would have to
12
+ # travel accepted -> release -> main in BOTH apps before this could go green.
13
+ #
14
+ # So the retirement is staged: this release adds /admin/emails and leaves this
15
+ # page working; each app's adoption task moves its link and its tests; a later
16
+ # engine minor deletes this file, its view, and its two routes once no
17
+ # consumer's main references them.
18
+ #
19
+ # It is not frozen in its broken state, though — see #index.
5
20
  class EmailImagesController < ApplicationController
6
21
  before_action :require_admin
7
22
 
@@ -11,6 +26,26 @@ module Studio
11
26
  @variants = Studio::EmailImage.variants
12
27
  end
13
28
 
29
+ # The canonical page this one is being retired in favour of, or nil when this
30
+ # app has not drawn it. Rendered as a banner at the top of the old view so an
31
+ # admin who lands here by a stale link is walked forward.
32
+ #
33
+ # The nil case is REAL, not defensive padding: /admin/emails is opt-in
34
+ # (Studio.draw_admin_emails_routes, default off because turf-monster owns the
35
+ # name), so on any app that has not opted in the helper does not exist and a
36
+ # bare call raises NameError — which 500s this page. Consumer CI caught
37
+ # exactly that on mcritchie-studio.
38
+ #
39
+ # Asks the ROUTER rather than the config flag: the flag is read at route-draw
40
+ # time, so a host that flips it afterwards would still have no route, and the
41
+ # router is the thing that actually knows.
42
+ def successor_path
43
+ return nil unless Rails.application.routes.named_routes.key?(:admin_emails)
44
+
45
+ admin_emails_path
46
+ end
47
+ helper_method :successor_path
48
+
14
49
  # PATCH /admin/email_images/:variant — upload/replace a banner.
15
50
  def update
16
51
  variant = params[:variant].to_s
@@ -0,0 +1,85 @@
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::EmailImage'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[update destroy]
19
+ before_action :require_uploads, only: %i[update destroy]
20
+
21
+ MAX_BYTES = 8.megabytes
22
+
23
+ def index
24
+ @entries = Studio::EmailImage.entries
25
+ @uploads_available = Studio::EmailImage.uploads_available?
26
+ end
27
+
28
+ # PATCH /admin/emails/:key — upload/replace this app's own banner.
29
+ def update
30
+ file = params[:image]
31
+ unless valid_image?(file)
32
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
33
+ return redirect_to admin_emails_path, alert: message, status: :see_other
34
+ end
35
+
36
+ rescue_and_log do
37
+ Studio::EmailImage.store(@key, io: file, content_type: file.content_type)
38
+ redirect_to admin_emails_path, notice: "#{Studio::EmailImage.label(@key)} banner updated.", status: :see_other
39
+ end
40
+ rescue StandardError
41
+ redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
42
+ end
43
+
44
+ # DELETE /admin/emails/:key — drop this app's override and fall back to the
45
+ # inherited default.
46
+ def destroy
47
+ # rescue_and_log, like #update: destroy is a WRITE path (it drops an
48
+ # ImageCache row and deletes the S3 object behind it), and the bare rescue
49
+ # below turns any failure into a friendly alert. Without the log that
50
+ # failure is invisible — the admin sees "try again" and nothing reaches
51
+ # ErrorLog to say why.
52
+ rescue_and_log do
53
+ reverted = Studio::EmailImage.revert(@key)
54
+ notice = if reverted
55
+ "#{Studio::EmailImage.label(@key)} reverted to the inherited default."
56
+ else
57
+ "#{Studio::EmailImage.label(@key)} was already using the inherited default."
58
+ end
59
+ redirect_to admin_emails_path, notice: notice, status: :see_other
60
+ end
61
+ rescue StandardError
62
+ redirect_to admin_emails_path, alert: "Couldn't revert the image. Please try again.", status: :see_other
63
+ end
64
+
65
+ private
66
+
67
+ def load_entry
68
+ @key = params[:key].to_s
69
+ head :not_found unless Studio::EmailImage.known?(@key)
70
+ end
71
+
72
+ def require_uploads
73
+ return if Studio::EmailImage.uploads_available?
74
+
75
+ redirect_to admin_emails_path, status: :see_other,
76
+ alert: "This app has no object storage configured, so email images can't be changed here yet."
77
+ end
78
+
79
+ def valid_image?(file)
80
+ file.respond_to?(:content_type) &&
81
+ file.content_type.to_s.start_with?("image/") &&
82
+ file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
83
+ end
84
+ end
85
+ 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::EmailImage.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