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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +55 -1
- data/app/assets/images/emails/logo-horizontal.png +0 -0
- data/app/assets/images/emails/magic-link-background.gif +0 -0
- data/app/assets/images/emails/newsletter-subscribed-background.gif +0 -0
- data/app/controllers/studio/emails_controller.rb +136 -6
- data/app/mailers/studio/newsletter_mailer.rb +43 -0
- data/app/mailers/user_mailer.rb +48 -1
- data/app/models/studio/email_setting.rb +131 -0
- data/app/services/studio/banner.rb +186 -0
- data/app/services/studio/email_catalog.rb +248 -19
- data/app/services/studio/email_preview_target.rb +195 -0
- data/app/views/layouts/branded_mailer.html.erb +23 -6
- data/app/views/studio/emails/_banner_editor.html.erb +192 -0
- data/app/views/studio/emails/_banner_scale.html.erb +56 -0
- data/app/views/studio/emails/_recipient_picker.html.erb +63 -0
- data/app/views/studio/emails/_recipient_repaint.html.erb +122 -0
- data/app/views/studio/emails/_row.html.erb +68 -4
- data/app/views/studio/emails/index.html.erb +30 -4
- data/app/views/studio/emails/orphan.html.erb +45 -0
- data/app/views/studio/emails/show.html.erb +364 -31
- data/app/views/studio/mailers/_layered_banner.html.erb +136 -0
- data/app/views/studio/modals/_image_upload.html.erb +55 -3
- data/app/views/studio/newsletter_mailer/subscribed.html.erb +33 -0
- data/app/views/studio/newsletter_mailer/subscribed.text.erb +13 -0
- data/app/views/user_mailer/magic_link.html.erb +13 -4
- data/db/migrate/20260812000000_create_studio_email_settings.rb +26 -0
- data/db/migrate/20260812210000_add_copy_to_studio_email_settings.rb +32 -0
- data/db/migrate/20260812220000_add_subject_to_studio_email_settings.rb +15 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -0
- metadata +20 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d675146488bc53f26f2ab9df774878f26b8c6fe714f8298fda17fbf85b3632de
|
|
4
|
+
data.tar.gz: afb41c11138b07903925c87abe6bab93d82476db7789226b79b2b38872f0ab2a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
| `
|
|
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`,
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
|
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
|
-
"#{
|
|
181
|
+
"#{label} reverted to the inherited default."
|
|
79
182
|
else
|
|
80
|
-
"#{
|
|
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
|
-
|
|
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
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -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
|
-
|
|
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
|