studio-engine 0.41.0 → 0.43.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 (35) 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 +152 -6
  8. data/app/mailers/studio/newsletter_mailer.rb +47 -0
  9. data/app/mailers/user_mailer.rb +55 -1
  10. data/app/models/studio/email_setting.rb +206 -0
  11. data/app/services/studio/banner.rb +191 -0
  12. data/app/services/studio/email_catalog.rb +387 -19
  13. data/app/services/studio/email_preview_target.rb +195 -0
  14. data/app/views/layouts/branded_mailer.html.erb +55 -6
  15. data/app/views/studio/emails/_banner_editor.html.erb +199 -0
  16. data/app/views/studio/emails/_banner_preview.html.erb +62 -0
  17. data/app/views/studio/emails/_banner_scale.html.erb +56 -0
  18. data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
  19. data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
  20. data/app/views/studio/emails/_row.html.erb +64 -82
  21. data/app/views/studio/emails/index.html.erb +34 -8
  22. data/app/views/studio/emails/orphan.html.erb +45 -0
  23. data/app/views/studio/emails/show.html.erb +468 -34
  24. data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
  25. data/app/views/studio/modals/_image_upload.html.erb +55 -3
  26. data/app/views/studio/newsletter_mailer/subscribed.html.erb +40 -0
  27. data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
  28. data/app/views/user_mailer/magic_link.html.erb +49 -12
  29. data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
  30. data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
  31. data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
  32. data/db/migrate/20260813010000_add_body_cta_footer_to_studio_email_settings.rb +32 -0
  33. data/lib/studio/version.rb +1 -1
  34. data/lib/studio.rb +12 -0
  35. metadata +22 -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: 60d9ce89189f6f027c2158888a403269310397f20cb86cddc88519e9ec4644bb
4
+ data.tar.gz: cb5f1ee58d3825e6b3ee705f07f8ede2cd2ebed14e2d86f03ddf22c1debb4b20
5
5
  SHA512:
6
- metadata.gz: e1d83d1b55f15fb4ecaa74a261b5db8c6cc874ca93166e4323814295caf16724b3bfd65dcda07c27918024ea5db9967e09fe63e0777d48c74c7b8f9c9b21d4ff
7
- data.tar.gz: 64d9556f766654a143e966d7b8f784c27773acfcf3bd35065f2cc9c24284a2113ab659dfe937ca090ff1030a0225d0ae0780e0e2f509fc6fc8868811d7e65dad
6
+ metadata.gz: 2d82567b9773871905899b6519699d95d5c05a73c5239914c3535d9a16198f6282c7d30309f07bb7961760d51a114ba031c4f20b83321ae1eb71f2ef7b7dc065
7
+ data.tar.gz: ae096e420cc8e25d8cb34013f6fc5eee1b41b5c102561b626bf67330b64e0115067ba0c237d32ab9b2c96f963181ae6c419f2fbf01f2f909504337980942cd9f
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,96 @@ 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
+ attrs = copy_params
129
+ Studio::EmailSetting.set_copy(@key, attrs)
130
+ # The button's on/off is a boolean, so it is not a COPY_FIELD and needs
131
+ # its own write — the same shape that once made hide_logo a dead control.
132
+ Studio::EmailSetting.set_cta_enabled(@key, attrs[:cta_enabled]) if attrs.key?(:cta_enabled)
133
+ # The footer is SHARED across every email, so it is stored under its own
134
+ # key rather than this one, and the page posts its two inputs on every
135
+ # save. update_footer (not set_footer) is what makes that safe: it writes
136
+ # only on a real change, so blank posts from a page nobody edited cannot
137
+ # wipe the footer every email sends.
138
+ #
139
+ # The form spells the footer's logo `footer_logo_url` because `logo_url`
140
+ # is already this email's OWN banner logo; the model takes it as
141
+ # `logo_url`, so the rename is undone here.
142
+ Studio::EmailSetting.update_footer(discord_url: attrs[:discord_url],
143
+ logo_url: attrs[:footer_logo_url])
144
+ if params.key?(:scrim_percent)
145
+ percent = params[:scrim_percent]
146
+ percent = nil unless percent.present? && Studio::EmailSetting::SCRIM_RANGE.cover?(percent.to_i)
147
+ Studio::EmailSetting.set_scrim(@key, percent)
148
+ end
149
+ redirect_to admin_email_path(@key), status: :see_other, notice: "Saved."
150
+ end
151
+ rescue StandardError
152
+ redirect_to admin_email_path(@key), status: :see_other,
153
+ alert: "Couldn't save the banner text. Please try again."
154
+ end
155
+
156
+ # PATCH /admin/emails/:key/logo — upload a logo for THIS email, or drop it.
157
+ #
158
+ # Its own action and its own ImageCache purpose: a logo is a small transparent
159
+ # mark, a banner is 3:1 artwork that may be animated, and reverting one must
160
+ # not touch the other.
161
+ def logo
162
+ if params[:remove].present?
163
+ rescue_and_log { Studio::EmailCatalog.revert_logo(@key) }
164
+ return redirect_to admin_email_path(@key), status: :see_other,
165
+ notice: "Back to the standard logo."
166
+ end
167
+
168
+ file = params[:image]
169
+ unless valid_image?(file)
170
+ message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, WebP or GIF under 8 MB."
171
+ return redirect_to admin_email_path(@key), alert: message, status: :see_other
172
+ end
173
+
174
+ rescue_and_log do
175
+ Studio::EmailCatalog.store_logo(@key, io: file, content_type: file.content_type)
176
+ redirect_to admin_email_path(@key), notice: "Logo updated.", status: :see_other
177
+ end
178
+ rescue StandardError
179
+ redirect_to admin_email_path(@key), alert: "Couldn't save the logo. Please try again.", status: :see_other
180
+ end
181
+
67
182
  # DELETE /admin/emails/:key — drop this app's override and fall back to the
68
183
  # inherited default.
69
184
  def destroy
@@ -74,10 +189,13 @@ module Studio
74
189
  # ErrorLog to say why.
75
190
  rescue_and_log do
76
191
  reverted = Studio::EmailCatalog.revert(@key)
192
+ # label() falls back to the humanised key, so an ORPHAN (no registry
193
+ # entry at all) still gets a readable message rather than a blank one.
194
+ label = Studio::EmailCatalog.label(@key).presence || @key.humanize
77
195
  notice = if reverted
78
- "#{Studio::EmailCatalog.label(@key)} reverted to the inherited default."
196
+ "#{label} reverted to the inherited default."
79
197
  else
80
- "#{Studio::EmailCatalog.label(@key)} was already using the inherited default."
198
+ "#{label} was already using the inherited default."
81
199
  end
82
200
  redirect_to admin_emails_path, notice: notice, status: :see_other
83
201
  end
@@ -87,9 +205,37 @@ module Studio
87
205
 
88
206
  private
89
207
 
208
+ # COPY_FIELDS plus :hide_logo, and the awkward shape is the honest one.
209
+ #
210
+ # This list was DERIVED from COPY_FIELDS precisely because a hand-typed one
211
+ # dropped :subject — the form posted it, strong params filtered it out, and
212
+ # the page still said "Saved." Deriving it then reintroduced exactly that bug
213
+ # for the one editable column that is NOT a copy field: hide_logo is a
214
+ # boolean rather than text, so it lives outside COPY_FIELDS and vanished from
215
+ # the permit list. Hiding the logo saved nothing and reported success.
216
+ #
217
+ # Every model-level logo test passed throughout, because set_copy was never
218
+ # the broken part. The filter lives here, so the assertion does too.
219
+ def copy_params
220
+ params.permit(*Studio::EmailSetting::COPY_FIELDS, :hide_logo, :cta_enabled,
221
+ :discord_url, :footer_logo_url)
222
+ .to_h.symbolize_keys
223
+ end
224
+
225
+ # A key is reachable when it is REGISTERED, or when this app still holds an
226
+ # upload for it.
227
+ #
228
+ # The second half matters because the standard set can change. An email that
229
+ # leaves it takes its page with it — and a host that had uploaded its own
230
+ # artwork was left with a live ImageCache row, a paid-for S3 object, and a
231
+ # 404 on the only page that could delete either. Keeping the page reachable
232
+ # for an orphan is what gives that operator a revert button.
90
233
  def load_entry
91
234
  @key = params[:key].to_s
92
- head :not_found unless Studio::EmailCatalog.known?(@key)
235
+ return if Studio::EmailCatalog.known?(@key)
236
+ return if Studio::EmailCatalog.record(@key).present?
237
+
238
+ head :not_found
93
239
  end
94
240
 
95
241
  def require_uploads
@@ -0,0 +1,47 @@
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
+ # No @cta_* here: this email's template renders no button, and setting
39
+ # ivars a view never reads is how a dead control looks like a live one.
40
+ @body = Studio::EmailCatalog.body(:newsletter_subscribed, name: @name)
41
+
42
+ subject = Studio::EmailCatalog.subject_for(:newsletter_subscribed, name: @name) ||
43
+ "You're subscribed to #{@app_name}"
44
+ mail(to: email, subject: subject)
45
+ end
46
+ end
47
+ end
@@ -21,11 +21,65 @@ 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 COPY BELOW THE BANNER is the operator's too, on the same terms as the
54
+ # banner's words: the registry carries what this file used to hard-code, so
55
+ # an app that never opens /admin/emails sends exactly what it sent before.
56
+ @body = Studio::EmailCatalog.body(:magic_link, name: name)
57
+ @cta_text = Studio::EmailCatalog.cta_text(:magic_link, name: name) if Studio::EmailCatalog.cta_enabled?(:magic_link)
58
+ @cta_color = Studio::EmailCatalog.cta_color(:magic_link)
59
+
60
+ # The operator's subject when they have set one, the hard-coded line
61
+ # otherwise — so an app that never visits /admin/emails is unchanged.
62
+ subject = Studio::EmailCatalog.subject_for(:magic_link, name: name) ||
63
+ "Your #{@app_name} sign-in link"
64
+ mail(to: email, subject: subject)
65
+ end
66
+
67
+ private
68
+
69
+ # The recipient's name when this app holds an account for them. Defensive about
70
+ # the whole chain — a host may have no User model, no display_name, or no row —
71
+ # because a subject line must never be the reason a send raises.
72
+ def recipient_name(email)
73
+ model = "User".safe_constantize
74
+ return nil unless model.respond_to?(:find_by)
75
+
76
+ record = model.find_by(email: email.to_s.strip.downcase)
77
+ %i[display_name name full_name].each do |method|
78
+ value = record.try(method)
79
+ return value if value.present?
80
+ end
81
+ nil
82
+ rescue StandardError
83
+ nil
30
84
  end
31
85
  end