studio-engine 0.41.0 → 0.42.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.
Files changed (33) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +55 -1
  4. data/app/assets/images/emails/logo-horizontal.png +0 -0
  5. data/app/assets/images/emails/magic-link-background.gif +0 -0
  6. data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
  7. data/app/controllers/studio/emails_controller.rb +136 -6
  8. data/app/mailers/studio/newsletter_mailer.rb +43 -0
  9. data/app/mailers/user_mailer.rb +48 -1
  10. data/app/models/studio/email_setting.rb +131 -0
  11. data/app/services/studio/banner.rb +186 -0
  12. data/app/services/studio/email_catalog.rb +248 -19
  13. data/app/services/studio/email_preview_target.rb +195 -0
  14. data/app/views/layouts/branded_mailer.html.erb +23 -6
  15. data/app/views/studio/emails/_banner_editor.html.erb +192 -0
  16. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  17. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  18. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  19. data/app/views/studio/emails/_row.html.erb +68 -4
  20. data/app/views/studio/emails/index.html.erb +30 -4
  21. data/app/views/studio/emails/orphan.html.erb +45 -0
  22. data/app/views/studio/emails/show.html.erb +364 -31
  23. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  24. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  25. data/app/views/studio/newsletter_mailer/subscribed.html.erb +33 -0
  26. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  27. data/app/views/user_mailer/magic_link.html.erb +13 -4
  28. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  29. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  30. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  31. data/lib/studio/version.rb +1 -1
  32. data/lib/studio.rb +12 -0
  33. metadata +20 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d4b5672c89f5dc5feeaf54432eed9992c022a69992c1ff58311b0b85a58a23e9
4
- data.tar.gz: 3a3489c4d4f8174d0d5facbf53f020505392f78e51f13a96a4c8f95367224608
3
+ metadata.gz: d675146488bc53f26f2ab9df774878f26b8c6fe714f8298fda17fbf85b3632de
4
+ data.tar.gz: afb41c11138b07903925c87abe6bab93d82476db7789226b79b2b38872f0ab2a
5
5
  SHA512:
6
- metadata.gz: e1d83d1b55f15fb4ecaa74a261b5db8c6cc874ca93166e4323814295caf16724b3bfd65dcda07c27918024ea5db9967e09fe63e0777d48c74c7b8f9c9b21d4ff
7
- data.tar.gz: 64d9556f766654a143e966d7b8f784c27773acfcf3bd35065f2cc9c24284a2113ab659dfe937ca090ff1030a0225d0ae0780e0e2f509fc6fc8868811d7e65dad
6
+ metadata.gz: f209b273cfacd2d9f428f43cc886b15e57ff7b1131a6753589d8ddcd17b07c9458c678ae1583e4826038a8d3cd2004757f460695952f60367677fbac7c44fc73
7
+ data.tar.gz: 5aa5fe2bf6fa67cfdf30aa6fdfe253f34f6c29715aa42ef56c95d892d61706d049c6b6f137908db54d6d8fda72ebb7e16e7b66e4c2705fc0286cbbcabbdcb9f8
data/CHANGELOG.md CHANGED
@@ -4,6 +4,103 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ### Changed
8
+
9
+ - **`email_change_confirmation` leaves the pre-registered set; `newsletter_subscribed`
10
+ joins it.** `STANDARD` means "the emails EVERY Studio app sends", and only
11
+ turf-monster sends an email-change confirmation — which it **already registers
12
+ itself**, with its own artwork, in `config/initializers/studio_emails.rb`. No
13
+ host loses a banner, and no host action is required for the removal.
14
+
15
+ What DOES break consumers is the ADDITION. Three suites assert against the
16
+ whole shared catalogue, so a new standard email fails them:
17
+
18
+ | Consumer | Assertion | Fix | State |
19
+ |---|---|---|---|
20
+ | turf-monster | `EmailRegistrationTest` compares the full key list | McRitchie-Studio/turf-monster#292 | merged |
21
+ | turf-monster | `AdminEmailsRenderTest` counts `tbody img` exactly | McRitchie-Studio/turf-monster#294 | merged |
22
+ | mcritchie-studio | `StudioEmailsPageTest` pins the registry list, an `email_change_confirmation` link, and the upload-rejection wording | McRitchie-Studio/mcritchie-studio#813 | merged |
23
+
24
+ The second turf fix is the easy one to miss: a layered standard email renders
25
+ its banner through an `<iframe>` rather than an `<img>`, so an exact image
26
+ count comes up one short. #292 alone does NOT turn that lane green.
27
+
28
+ Consumer CI builds each app against its **default branch**, so all three had to
29
+ reach `main` before this engine's consumer lanes could go green. All three now
30
+ have, and the lanes pass. Nothing on this branch ever could have turned them
31
+ green — a red consumer lane here is a consumer-side fix, not an engine defect.
32
+
33
+ The artwork `emails/email-change-confirmation.gif` still ships, so a host that
34
+ wants the entry can register it.
35
+
36
+ ### Added
37
+
38
+ - **`Studio::NewsletterMailer` — a sendable "you're on the list" email.**
39
+ Namespaced under `Studio::` on purpose: a host that defines its own top-level
40
+ `UserMailer` (McRitchie Studio does) SHADOWS the engine's outright, so an
41
+ action added there would reach only the apps that never customised their mail.
42
+ Layered-native — animated background with the greeting as live HTML on top,
43
+ and no flat `default_asset`, because a baked-in copy of the same picture is a
44
+ second thing to keep in sync and would never be shown. `unsubscribe_url:` is
45
+ optional and the line renders only when a host wires a real one.
46
+
47
+ - **The banner's WORDS are operator-editable on /admin/emails.** Header,
48
+ a name-free fallback header, sub-text, subject, logo and tint, saved per app in
49
+ a new `studio_email_settings` row and resolved operator > registry > default.
50
+ `{name}` and `{app}` are filled per recipient — so the mailer now supplies WHO
51
+ the recipient is (`Studio::Banner.for(key, name:)`) and the operator supplies
52
+ what the banner says about them. A mailer that hands over a finished header
53
+ takes the wording away from the operator: the field still accepts the edit and
54
+ the email still ignores it, which is a control that lies.
55
+
56
+ An unresolved `{name}` never reaches an inbox. The header falls back to its
57
+ name-free variant; a subject, which has no second field, drops the token along
58
+ with the punctuation holding it — "Sign in to {app}, {name}" sends as
59
+ "Sign in to Studio" to someone with no name on file.
60
+
61
+ - **The email manager previews what actually SHIPS.** Each email renders the
62
+ real layered banner through the mailer's own partial (not a re-creation of it),
63
+ repaints as the operator types, and offers an example recipient — an admin, who
64
+ usually has a full name on file, and a member, who often has none. The nameless
65
+ case is the one the fallback header exists for and the one nobody thinks to
66
+ check. Preview hooks are opt-in (`preview: true`); with them off the email
67
+ markup is byte-identical, asserted by test.
68
+
69
+ What makes that true rather than aspirational: the engine's own
70
+ `UserMailer#magic_link` ADOPTS the layered banner (`Studio::Banner.for`), and
71
+ /admin/emails builds its preview from the same call — one expression, so the
72
+ two cannot disagree. Before this, the manager drew a layered banner with live
73
+ "Welcome Alex!" text for a sign-in email that shipped flat artwork with the
74
+ words baked in: a preview showing something no recipient receives. A test
75
+ renders the mailer and the manager for the same key and compares the artwork
76
+ and the layered-ness, so the two are held together rather than trusted to
77
+ match. An app that registers no layered artwork still sends the plain `<img>`
78
+ — layered is opt-in, never a migration.
79
+
80
+ - **Animated GIF banners upload without losing their animation.** The cropper
81
+ paints onto a canvas and calls `toBlob(..., "image/png")`, which keeps frame one
82
+ and discards the rest — silently, leaving a good-looking still. `allowGifs: true`
83
+ routes a GIF around the cropper and stores the original bytes under a `.gif`
84
+ key.
85
+
86
+ - **`Studio::EmailPreviewTarget`** — who an email is previewed as, read from the
87
+ host's own users with avatar and initials, degrading to a sample when an app has
88
+ none.
89
+
90
+ ### Fixed
91
+
92
+ - **The manager reported artwork that was not being sent.** A layered-native email
93
+ (no flat `default_asset`) drew an empty box and a "sends without a banner" badge
94
+ while it was sending one, and an email with BOTH assets previewed the flat one —
95
+ a picture with the words baked in that a layered mailer never sends. The page
96
+ and the `<img>` fallback now resolve in opposite orders, each documented, because
97
+ they answer different questions.
98
+
99
+ - **A doc comment leaked onto the page.** An ERB tag inside an ERB comment closes
100
+ it at its own delimiter; the rest of the sentence rendered above the banner. The
101
+ existing guard was real and only ran on the index, so it never saw the page that
102
+ shipped it.
103
+
7
104
  ### Added
8
105
 
9
106
  - **A browser lane — the engine now runs Playwright against its own partials.**
data/README.md CHANGED
@@ -361,7 +361,7 @@ declaring anything:
361
361
  | Key | Label |
362
362
  |-----|-------|
363
363
  | `magic_link` | Magic-link sign-in |
364
- | `email_change_confirmation` | Email change confirmation |
364
+ | `newsletter_subscribed` | Newsletter subscribed |
365
365
 
366
366
  A host adds its own workflows from an initializer, mirroring
367
367
  `Studio::ModelPage.register`:
@@ -387,6 +387,60 @@ than raising.
387
387
  > `Studio::EmailImage` is the old name for this module and still works as a
388
388
  > delegating shim. It is deprecated; prefer `Studio::EmailCatalog`.
389
389
 
390
+ ### Layered banners — a background image with live text on it
391
+
392
+ An email's banner can be a picture with the header, sub-text and logo rendered
393
+ as HTML **on top**, rather than drawn into the image:
394
+
395
+ ```ruby
396
+ def magic_link(email, token)
397
+ # The mailer supplies WHO the recipient is. What the banner SAYS about them —
398
+ # the greeting, the sub-text, whether the name is used at all — belongs to the
399
+ # operator, editable on /admin/emails. Pass a finished `header:` here and those
400
+ # fields still accept edits no inbox ever sees.
401
+ @banner = Studio::Banner.for(:magic_link, name: recipient_name(email))
402
+ # ...
403
+ end
404
+ ```
405
+
406
+ `layouts/branded_mailer` renders it. The engine's own `UserMailer#magic_link`
407
+ does exactly the above, and /admin/emails builds its preview from the same
408
+ `Studio::Banner.for` call — so preview and send cannot disagree.
409
+
410
+ A mailer that sets `@banner_url` instead still renders a plain `<img>` exactly
411
+ as before, and `Studio::Banner.for` returns `nil` when the app has registered no
412
+ layered artwork, so the flat path is what an unadopted app keeps getting —
413
+ layered is opt-in, never a migration.
414
+
415
+ Register the artwork once and every app inherits it:
416
+
417
+ ```ruby
418
+ Studio::EmailCatalog.register("magic_link",
419
+ background: "emails/magic-link-background.gif",
420
+ logo: "emails/logo-horizontal.png",
421
+ scrim: 0.40)
422
+ ```
423
+
424
+ **Why layered rather than composited.** Drawing the text into the image gives
425
+ pixel-exact brand typography everywhere, but it cannot have an ANIMATED
426
+ background AND per-recipient text — composing a greeting into sixty frames means
427
+ a multi-megabyte GIF per recipient, per send. Layering separates them.
428
+
429
+ **What it costs.** Gmail and Outlook strip webfonts, so the heading falls back to
430
+ a system face rather than the brand font. In exchange the text survives blocked
431
+ images (Outlook desktop blocks by default), stays selectable, and nothing is
432
+ generated at send time.
433
+
434
+ **The scrim** is a wash between artwork and type, on by default at 0.40.
435
+ Background art is chosen to look good, not to guarantee contrast, and white text
436
+ over a pale sky cannot be read. Pass `scrim: 0` for artwork dark enough to carry
437
+ the type itself.
438
+
439
+ **The markup is deliberately old-fashioned.** Outlook on Windows renders through
440
+ Word and ignores `background-image`, so the picture is carried by the `<td
441
+ background>` attribute, by CSS, and by a VML block inside an `mso` conditional.
442
+ Remove any one of the three and a client loses the banner.
443
+
390
444
  ### Live preview
391
445
 
392
446
  `preview:` is any callable returning a `Mail`. It powers `/admin/emails/:key`,
@@ -15,23 +15,48 @@ module Studio
15
15
  # is currently sending.
16
16
  class EmailsController < ApplicationController
17
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]
18
+ before_action :load_entry, only: %i[show raw update destroy settings copy logo]
19
+ before_action :require_uploads, only: %i[update destroy logo]
20
20
 
21
21
  MAX_BYTES = 8.megabytes
22
22
 
23
23
  def index
24
24
  @entries = Studio::EmailCatalog.entries
25
25
  @uploads_available = Studio::EmailCatalog.uploads_available?
26
+
27
+ # The list renders every banner and subject AS THEY WOULD ARRIVE, which has
28
+ # no answer until someone is receiving them.
29
+ @targets = Studio::EmailPreviewTarget.all
30
+ @target = Studio::EmailPreviewTarget.resolve(params[:target])
31
+ @preview_name = @target&.name
26
32
  end
27
33
 
28
34
  # GET /admin/emails/:key — one email: its banner, its type, and a live
29
35
  # preview built from the host's sample data.
30
36
  def show
37
+ # nil for an ORPHAN — an email that left the registry while this app still
38
+ # holds an upload for it. The view renders a minimal page whose only real
39
+ # affordance is Revert, which is the whole reason the route stays open.
31
40
  @entry = Studio::EmailCatalog.entry(@key)
41
+ @orphan = @entry.nil?
42
+ return render(:orphan) if @orphan
43
+
32
44
  @subject = Studio::EmailCatalog.preview_subject(@key)
33
45
  @preview_error = Studio::EmailCatalog.preview_error(@key)
34
46
  @uploads_available = Studio::EmailCatalog.uploads_available?
47
+
48
+ # WHO this is previewed as. The banner greets by name, so "what does this
49
+ # email look like" has no answer until someone is receiving it — and the
50
+ # two people who break email differently (an admin with a full name on
51
+ # file, a member with none) are both offered.
52
+ @targets = Studio::EmailPreviewTarget.all
53
+ @target = Studio::EmailPreviewTarget.resolve(params[:target])
54
+ @preview_name = @target&.name
55
+
56
+ # The banner AS IT ARRIVES, built the same way a mailer builds it so the
57
+ # page cannot show something the inbox never gets.
58
+ @banner = Studio::Banner.for(@key, name: @preview_name)
59
+ @subject = Studio::EmailCatalog.subject_for(@key, name: @preview_name) || @subject
35
60
  end
36
61
 
37
62
  # GET /admin/emails/:key/raw — the rendered email itself, as the iframe
@@ -52,7 +77,7 @@ module Studio
52
77
  def update
53
78
  file = params[:image]
54
79
  unless valid_image?(file)
55
- message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
80
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, WebP or GIF under 8 MB."
56
81
  return redirect_to admin_emails_path, alert: message, status: :see_other
57
82
  end
58
83
 
@@ -64,6 +89,81 @@ module Studio
64
89
  redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
65
90
  end
66
91
 
92
+ # PATCH /admin/emails/:key/settings — operator-tunable knobs.
93
+ #
94
+ # The scrim is the dial between a readable header and a visible picture, and
95
+ # the right value depends on artwork that changes without a deploy. Blank
96
+ # clears the override so the email falls back to the registry default rather
97
+ # than being pinned to whatever that default was on the day.
98
+ def settings
99
+ percent = params[:scrim_percent]
100
+
101
+ if percent.present? && !Studio::EmailSetting::SCRIM_RANGE.cover?(percent.to_i)
102
+ return redirect_to admin_email_path(@key), status: :see_other,
103
+ alert: "Tint must be between 0 and 100."
104
+ end
105
+
106
+ rescue_and_log do
107
+ Studio::EmailSetting.set_scrim(@key, percent)
108
+ redirect_to admin_email_path(@key), status: :see_other,
109
+ notice: percent.present? ? "Tint set to #{percent.to_i}%." : "Tint reset to the default."
110
+ end
111
+ rescue StandardError
112
+ redirect_to admin_email_path(@key), status: :see_other,
113
+ alert: "Couldn't save the tint. Please try again."
114
+ end
115
+
116
+ # PATCH /admin/emails/:key/copy — the banner's words and logo.
117
+ #
118
+ # Separate from #settings rather than one big form: the tint is a slider you
119
+ # nudge while looking at the picture, the copy is a sentence you write. They
120
+ # save independently so tuning one never risks clobbering an unsaved edit to
121
+ # the other.
122
+ # Saves EVERYTHING the page edits, in one write. The page has a single Save
123
+ # button, so it must: three buttons that each saved a third of the form meant
124
+ # an operator who changed the subject and the tint had to notice there were
125
+ # two places to press.
126
+ def copy
127
+ rescue_and_log do
128
+ Studio::EmailSetting.set_copy(@key, copy_params)
129
+ if params.key?(:scrim_percent)
130
+ percent = params[:scrim_percent]
131
+ percent = nil unless percent.present? && Studio::EmailSetting::SCRIM_RANGE.cover?(percent.to_i)
132
+ Studio::EmailSetting.set_scrim(@key, percent)
133
+ end
134
+ redirect_to admin_email_path(@key), status: :see_other, notice: "Saved."
135
+ end
136
+ rescue StandardError
137
+ redirect_to admin_email_path(@key), status: :see_other,
138
+ alert: "Couldn't save the banner text. Please try again."
139
+ end
140
+
141
+ # PATCH /admin/emails/:key/logo — upload a logo for THIS email, or drop it.
142
+ #
143
+ # Its own action and its own ImageCache purpose: a logo is a small transparent
144
+ # mark, a banner is 3:1 artwork that may be animated, and reverting one must
145
+ # not touch the other.
146
+ def logo
147
+ if params[:remove].present?
148
+ rescue_and_log { Studio::EmailCatalog.revert_logo(@key) }
149
+ return redirect_to admin_email_path(@key), status: :see_other,
150
+ notice: "Back to the standard logo."
151
+ end
152
+
153
+ file = params[:image]
154
+ unless valid_image?(file)
155
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, WebP or GIF under 8 MB."
156
+ return redirect_to admin_email_path(@key), alert: message, status: :see_other
157
+ end
158
+
159
+ rescue_and_log do
160
+ Studio::EmailCatalog.store_logo(@key, io: file, content_type: file.content_type)
161
+ redirect_to admin_email_path(@key), notice: "Logo updated.", status: :see_other
162
+ end
163
+ rescue StandardError
164
+ redirect_to admin_email_path(@key), alert: "Couldn't save the logo. Please try again.", status: :see_other
165
+ end
166
+
67
167
  # DELETE /admin/emails/:key — drop this app's override and fall back to the
68
168
  # inherited default.
69
169
  def destroy
@@ -74,10 +174,13 @@ module Studio
74
174
  # ErrorLog to say why.
75
175
  rescue_and_log do
76
176
  reverted = Studio::EmailCatalog.revert(@key)
177
+ # label() falls back to the humanised key, so an ORPHAN (no registry
178
+ # entry at all) still gets a readable message rather than a blank one.
179
+ label = Studio::EmailCatalog.label(@key).presence || @key.humanize
77
180
  notice = if reverted
78
- "#{Studio::EmailCatalog.label(@key)} reverted to the inherited default."
181
+ "#{label} reverted to the inherited default."
79
182
  else
80
- "#{Studio::EmailCatalog.label(@key)} was already using the inherited default."
183
+ "#{label} was already using the inherited default."
81
184
  end
82
185
  redirect_to admin_emails_path, notice: notice, status: :see_other
83
186
  end
@@ -87,9 +190,36 @@ module Studio
87
190
 
88
191
  private
89
192
 
193
+ # COPY_FIELDS plus :hide_logo, and the awkward shape is the honest one.
194
+ #
195
+ # This list was DERIVED from COPY_FIELDS precisely because a hand-typed one
196
+ # dropped :subject — the form posted it, strong params filtered it out, and
197
+ # the page still said "Saved." Deriving it then reintroduced exactly that bug
198
+ # for the one editable column that is NOT a copy field: hide_logo is a
199
+ # boolean rather than text, so it lives outside COPY_FIELDS and vanished from
200
+ # the permit list. Hiding the logo saved nothing and reported success.
201
+ #
202
+ # Every model-level logo test passed throughout, because set_copy was never
203
+ # the broken part. The filter lives here, so the assertion does too.
204
+ def copy_params
205
+ params.permit(*Studio::EmailSetting::COPY_FIELDS, :hide_logo)
206
+ .to_h.symbolize_keys
207
+ end
208
+
209
+ # A key is reachable when it is REGISTERED, or when this app still holds an
210
+ # upload for it.
211
+ #
212
+ # The second half matters because the standard set can change. An email that
213
+ # leaves it takes its page with it — and a host that had uploaded its own
214
+ # artwork was left with a live ImageCache row, a paid-for S3 object, and a
215
+ # 404 on the only page that could delete either. Keeping the page reachable
216
+ # for an orphan is what gives that operator a revert button.
90
217
  def load_entry
91
218
  @key = params[:key].to_s
92
- head :not_found unless Studio::EmailCatalog.known?(@key)
219
+ return if Studio::EmailCatalog.known?(@key)
220
+ return if Studio::EmailCatalog.record(@key).present?
221
+
222
+ head :not_found
93
223
  end
94
224
 
95
225
  def require_uploads
@@ -0,0 +1,43 @@
1
+ module Studio
2
+ # The "you're on the list" email.
3
+ #
4
+ # NAMESPACED, unlike the engine's UserMailer, and that is the whole point. A
5
+ # host that defines its own top-level `UserMailer` (McRitchie Studio does)
6
+ # SHADOWS the engine's outright — Zeitwerk resolves the constant to the host's
7
+ # file and the engine's actions simply do not exist there. Putting this under
8
+ # Studio:: means every app inherits a sendable newsletter email on day one,
9
+ # and a host that wants different copy overrides the VIEW rather than losing
10
+ # the action.
11
+ class NewsletterMailer < ApplicationMailer
12
+ # Branded shell — banner + card. The layout renders @banner layered (artwork
13
+ # with live text on top) and falls back to the flat @banner_url <img>.
14
+ layout "branded_mailer"
15
+
16
+ # `email` is a raw string: someone can join a mailing list without ever
17
+ # holding an account, so this must not require a User.
18
+ #
19
+ # unsubscribe_url is OPTIONAL and the line renders only when it is given.
20
+ # A newsletter needs a way out, but a hard-coded dead link is worse than an
21
+ # honest omission — a host wires its real one and the line appears.
22
+ def subscribed(email, name: nil, unsubscribe_url: nil)
23
+ @app_name = Studio.app_name
24
+ @email = email
25
+ @name = name.presence
26
+ @unsubscribe_url = unsubscribe_url.presence
27
+
28
+ # The mailer passes WHO, not WHAT THE BANNER SAYS. The wording — including
29
+ # whether it greets by name at all — is the operator's, editable on
30
+ # /admin/emails. Handing over a finished header here would make that field
31
+ # accept edits and change nothing.
32
+ @banner = Studio::Banner.for(:newsletter_subscribed, name: @name)
33
+ @banner_alt = "#{@banner&.header} — #{@app_name}"
34
+ # The floor, exactly as UserMailer does it: if no layered artwork is
35
+ # registered the layout renders the flat <img> instead of nothing.
36
+ @banner_url = Studio::EmailCatalog.resolved_url(:newsletter_subscribed)
37
+
38
+ subject = Studio::EmailCatalog.subject_for(:newsletter_subscribed, name: @name) ||
39
+ "You're subscribed to #{@app_name}"
40
+ mail(to: email, subject: subject)
41
+ end
42
+ end
43
+ end
@@ -21,11 +21,58 @@ class UserMailer < ApplicationMailer
21
21
  @app_name = Studio.app_name
22
22
  @email = email
23
23
  @magic_url = magic_link_url_for(token)
24
+ # ONE lookup, two consumers. recipient_name queries the host's users table,
25
+ # and the banner and the subject want the same answer — asking twice on a
26
+ # delivery path buys nothing and risks the two disagreeing.
27
+ name = recipient_name(email)
28
+ # THE LAYERED BANNER, when this app has layered artwork for the email.
29
+ #
30
+ # The mailer supplies only WHO the recipient is. What the banner SAYS about
31
+ # them — the greeting, the sub-text, whether the name is used at all — is
32
+ # the operator's, editable on /admin/emails. Handing over a finished header
33
+ # here would leave those fields accepting edits no inbox ever sees.
34
+ #
35
+ # Why this assignment exists at all: /admin/emails draws its preview from
36
+ # this same Studio::Banner.for call, so a mailer that set only @banner_url
37
+ # made the manager show a layered banner with live text for an email that
38
+ # shipped flat baked-in artwork. Preview and send now resolve through one
39
+ # expression and cannot disagree.
40
+ #
41
+ # nil when the app registers no background (or owns the artwork itself), and
42
+ # the layout falls through to the flat <img> below.
43
+ @banner = Studio::Banner.for(:magic_link, name: name)
24
44
  # resolved_url, not url: this app's own upload if it has one, otherwise the
25
45
  # engine's default banner — which is what makes a brand-new app's sign-in
26
46
  # email branded on day one. nil renders bannerless.
47
+ #
48
+ # KEPT AS THE FLOOR. @banner above wins when it is present; this is what an
49
+ # app with no layered artwork registered still sends, exactly as before.
50
+ # Layered is opt-in, never a migration.
27
51
  @banner_url = Studio::EmailCatalog.resolved_url(:magic_link)
28
52
  @banner_alt = "Your #{@app_name} sign-in link"
29
- mail(to: email, subject: "Your #{@app_name} sign-in link")
53
+ # The operator's subject when they have set one, the hard-coded line
54
+ # otherwise — so an app that never visits /admin/emails is unchanged.
55
+ subject = Studio::EmailCatalog.subject_for(:magic_link, name: name) ||
56
+ "Your #{@app_name} sign-in link"
57
+ mail(to: email, subject: subject)
58
+ end
59
+
60
+ private
61
+
62
+ # The recipient's name when this app holds an account for them. Defensive about
63
+ # the whole chain — a host may have no User model, no display_name, or no row —
64
+ # because a subject line must never be the reason a send raises.
65
+ def recipient_name(email)
66
+ model = "User".safe_constantize
67
+ return nil unless model.respond_to?(:find_by)
68
+
69
+ record = model.find_by(email: email.to_s.strip.downcase)
70
+ %i[display_name name full_name].each do |method|
71
+ value = record.try(method)
72
+ return value if value.present?
73
+ end
74
+ nil
75
+ rescue StandardError
76
+ nil
30
77
  end
31
78
  end
@@ -0,0 +1,131 @@
1
+ module Studio
2
+ # An operator's per-email overrides, editable from /admin/emails.
3
+ #
4
+ # The registry (code) supplies defaults; a row here overrides them for THIS
5
+ # app. That split matters: the scrim is the dial between a readable header and
6
+ # a visible picture, and the right value depends on artwork that changes
7
+ # without a deploy — so it has to be tunable by the person looking at it.
8
+ #
9
+ # Nil-safe throughout, because the table is installed by a migration the host
10
+ # runs. An app that has not run it yet must still send email.
11
+ class EmailSetting < ApplicationRecord
12
+ self.table_name = "studio_email_settings"
13
+
14
+ SCRIM_RANGE = (0..100).freeze
15
+
16
+ validates :email_key, presence: true, uniqueness: true
17
+ validates :scrim_percent, numericality: { only_integer: true,
18
+ greater_than_or_equal_to: SCRIM_RANGE.min,
19
+ less_than_or_equal_to: SCRIM_RANGE.max },
20
+ allow_nil: true
21
+
22
+ # The banner's words and logo. Each is nil until the operator sets it, and
23
+ # nil means INHERIT — never "empty".
24
+ COPY_FIELDS = %i[header header_fallback subtext logo_url subject].freeze
25
+
26
+ class << self
27
+ # The saved scrim for this email as a 0.0-1.0 fraction, or nil when the
28
+ # operator has not set one (the registry default then applies).
29
+ def scrim_for(key)
30
+ return nil unless table_ready?
31
+
32
+ percent = for_key(key)&.scrim_percent
33
+ percent.nil? ? nil : percent / 100.0
34
+ end
35
+
36
+ # One saved copy field, or nil to inherit. Blank is stored as nil by
37
+ # #set_copy, so a blank return here always means "not set".
38
+ def copy_for(key, field)
39
+ return nil unless table_ready?
40
+ return nil unless COPY_FIELDS.include?(field.to_sym)
41
+
42
+ for_key(key)&.public_send(field).presence
43
+ end
44
+
45
+ # The row for this email, memoised PER REQUEST.
46
+ #
47
+ # Building one banner asks for the header, the fallback, the sub-text, the
48
+ # logo, the subject, the scrim and hide_logo — seven find_by calls for one
49
+ # row, on the mail DELIVERY path, and multiplied by every row on
50
+ # /admin/emails. The cache is request-scoped rather than a class variable
51
+ # so a write in one request cannot be served to the next.
52
+ # IsolatedExecutionState, not Thread.current: Puma reuses threads, so a
53
+ # thread-local outlives the request that filled it and the next request
54
+ # served by that thread would get the previous one's row. Rails resets
55
+ # IsolatedExecutionState around every request and job.
56
+ def for_key(key)
57
+ cache = ActiveSupport::IsolatedExecutionState[:studio_email_settings] ||= {}
58
+ return cache[key.to_s] if cache.key?(key.to_s)
59
+
60
+ cache[key.to_s] = find_by(email_key: key.to_s)
61
+ end
62
+
63
+ # Called after any write, because a memoised row that outlives its update
64
+ # serves the operator their old copy back and looks like the save failed.
65
+ def forget!(key = nil)
66
+ cache = ActiveSupport::IsolatedExecutionState[:studio_email_settings]
67
+ return if cache.nil?
68
+
69
+ key.nil? ? cache.clear : cache.delete(key.to_s)
70
+ end
71
+
72
+ # True when the operator has explicitly hidden the logo — which is a
73
+ # different answer from "no logo url saved" (that one inherits).
74
+ def hide_logo?(key)
75
+ return false unless table_ready?
76
+
77
+ for_key(key)&.hide_logo || false
78
+ rescue ActiveRecord::ActiveRecordError
79
+ false
80
+ end
81
+
82
+ # Save the words. A blank field is stored as NULL rather than "", so
83
+ # clearing a box means "go back to the registry default" — the same
84
+ # gesture that resets the tint.
85
+ def set_copy(key, attrs)
86
+ return nil unless table_ready?
87
+
88
+ record = find_or_initialize_by(email_key: key.to_s)
89
+ COPY_FIELDS.each do |field|
90
+ next unless attrs.key?(field) || attrs.key?(field.to_s)
91
+
92
+ record.public_send(:"#{field}=", (attrs[field] || attrs[field.to_s]).presence)
93
+ end
94
+ # ONLY when the form carried it. Two separate cards post to this method,
95
+ # and an absent checkbox means "this form does not manage the logo", not
96
+ # "show the logo" — writing false either way let saving the subject
97
+ # silently un-hide a logo the operator had hidden.
98
+ if attrs.key?(:hide_logo) || attrs.key?("hide_logo")
99
+ record.hide_logo = ActiveModel::Type::Boolean.new.cast(attrs[:hide_logo] || attrs["hide_logo"]) || false
100
+ end
101
+ record.save!
102
+ # Drop the memo, or the operator is shown the value they just replaced —
103
+ # the same "saved successfully, changed nothing" shape the permit bug had.
104
+ forget!(key)
105
+ record
106
+ end
107
+
108
+ # Store a percent, or clear the override with nil/blank so the email falls
109
+ # back to the registry default rather than being pinned to whatever the
110
+ # default happened to be on the day.
111
+ def set_scrim(key, percent)
112
+ return nil unless table_ready?
113
+
114
+ record = find_or_initialize_by(email_key: key.to_s)
115
+ record.scrim_percent = percent.presence&.to_i
116
+ record.save!
117
+ forget!(key)
118
+ record
119
+ end
120
+
121
+ # Reference the constant directly so Zeitwerk autoloads it — defined?()
122
+ # does NOT trigger autoload, so it reads "undefined" for a not-yet-loaded
123
+ # const and would silently disable every setting.
124
+ def table_ready?
125
+ table_exists?
126
+ rescue ActiveRecord::ActiveRecordError, NameError
127
+ false
128
+ end
129
+ end
130
+ end
131
+ end