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
@@ -0,0 +1,206 @@
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 body cta_text cta_color].freeze
25
+
26
+ # The footer is shared by every email this app sends, so it is stored once
27
+ # under a reserved key rather than copied onto each row. Underscored so it
28
+ # cannot collide with a registry key, which is always a plain identifier.
29
+ FOOTER_KEY = "_footer".freeze
30
+ FOOTER_FIELDS = %i[discord_url logo_url].freeze
31
+
32
+ class << self
33
+ # The saved scrim for this email as a 0.0-1.0 fraction, or nil when the
34
+ # operator has not set one (the registry default then applies).
35
+ def scrim_for(key)
36
+ return nil unless table_ready?
37
+
38
+ percent = for_key(key)&.scrim_percent
39
+ percent.nil? ? nil : percent / 100.0
40
+ end
41
+
42
+ # One saved copy field, or nil to inherit. Blank is stored as nil by
43
+ # #set_copy, so a blank return here always means "not set".
44
+ def copy_for(key, field)
45
+ return nil unless table_ready?
46
+ return nil unless COPY_FIELDS.include?(field.to_sym)
47
+
48
+ for_key(key)&.public_send(field).presence
49
+ end
50
+
51
+ # The row for this email, memoised PER REQUEST.
52
+ #
53
+ # Building one banner asks for the header, the fallback, the sub-text, the
54
+ # logo, the subject, the scrim and hide_logo — seven find_by calls for one
55
+ # row, on the mail DELIVERY path, and multiplied by every row on
56
+ # /admin/emails. The cache is request-scoped rather than a class variable
57
+ # so a write in one request cannot be served to the next.
58
+ # IsolatedExecutionState, not Thread.current: Puma reuses threads, so a
59
+ # thread-local outlives the request that filled it and the next request
60
+ # served by that thread would get the previous one's row. Rails resets
61
+ # IsolatedExecutionState around every request and job.
62
+ def for_key(key)
63
+ cache = ActiveSupport::IsolatedExecutionState[:studio_email_settings] ||= {}
64
+ return cache[key.to_s] if cache.key?(key.to_s)
65
+
66
+ cache[key.to_s] = find_by(email_key: key.to_s)
67
+ end
68
+
69
+ # Called after any write, because a memoised row that outlives its update
70
+ # serves the operator their old copy back and looks like the save failed.
71
+ def forget!(key = nil)
72
+ cache = ActiveSupport::IsolatedExecutionState[:studio_email_settings]
73
+ return if cache.nil?
74
+
75
+ key.nil? ? cache.clear : cache.delete(key.to_s)
76
+ end
77
+
78
+ # The shared footer, as a plain hash. Reads through the same per-request
79
+ # memo as everything else, so rendering it on every email in a list costs
80
+ # one query rather than one per email.
81
+ # The saved footer, or NIL when the operator has never touched it.
82
+ #
83
+ # nil and {} are different answers and the distinction is load-bearing: no
84
+ # row means "apply the defaults", while a row whose fields are blank means
85
+ # "I cleared these on purpose". Returning {} for both made clearing the
86
+ # logo hand the default straight back, so the field could not be emptied.
87
+ def footer
88
+ return nil unless table_ready?
89
+
90
+ row = for_key(FOOTER_KEY)
91
+ return nil if row.nil?
92
+
93
+ FOOTER_FIELDS.index_with { |field| row.public_send(field).presence }
94
+ end
95
+
96
+ # Write the footer ONLY when it actually changes.
97
+ #
98
+ # There is one Save for the whole page, so every save posts the footer
99
+ # inputs — blank ones included, from a page where the operator only touched
100
+ # the subject. Writing those blanks created a row of nils, which reads the
101
+ # same as "cleared", so the shared footer vanished from every email the app
102
+ # sends and could not be recovered without retyping it.
103
+ #
104
+ # Comparing against what is stored keeps both meanings: blanks matching an
105
+ # untouched footer write nothing, blanks replacing a stored value clear it.
106
+ def update_footer(discord_url: nil, logo_url: nil)
107
+ posted = { discord_url: discord_url.presence, logo_url: logo_url.presence }
108
+ stored = footer
109
+
110
+ return nil if stored.nil? && posted.values.all?(&:nil?)
111
+ return nil if stored.present? && stored.slice(:discord_url, :logo_url) == posted
112
+
113
+ set_footer(posted)
114
+ end
115
+
116
+ def set_footer(attrs)
117
+ return nil unless table_ready?
118
+
119
+ record = find_or_initialize_by(email_key: FOOTER_KEY)
120
+ FOOTER_FIELDS.each do |field|
121
+ next unless attrs.key?(field) || attrs.key?(field.to_s)
122
+
123
+ record.public_send(:"#{field}=", (attrs[field] || attrs[field.to_s]).presence)
124
+ end
125
+ record.save!
126
+ forget!(FOOTER_KEY)
127
+ record
128
+ end
129
+
130
+ # nil when the operator has not decided — the registry then answers.
131
+ def cta_enabled_for(key)
132
+ return nil unless table_ready?
133
+
134
+ for_key(key)&.cta_enabled
135
+ end
136
+
137
+ def set_cta_enabled(key, value)
138
+ return nil unless table_ready?
139
+
140
+ record = find_or_initialize_by(email_key: key.to_s)
141
+ record.cta_enabled = value.nil? ? nil : ActiveModel::Type::Boolean.new.cast(value)
142
+ record.save!
143
+ forget!(key)
144
+ record
145
+ end
146
+
147
+ # True when the operator has explicitly hidden the logo — which is a
148
+ # different answer from "no logo url saved" (that one inherits).
149
+ def hide_logo?(key)
150
+ return false unless table_ready?
151
+
152
+ for_key(key)&.hide_logo || false
153
+ rescue ActiveRecord::ActiveRecordError
154
+ false
155
+ end
156
+
157
+ # Save the words. A blank field is stored as NULL rather than "", so
158
+ # clearing a box means "go back to the registry default" — the same
159
+ # gesture that resets the tint.
160
+ def set_copy(key, attrs)
161
+ return nil unless table_ready?
162
+
163
+ record = find_or_initialize_by(email_key: key.to_s)
164
+ COPY_FIELDS.each do |field|
165
+ next unless attrs.key?(field) || attrs.key?(field.to_s)
166
+
167
+ record.public_send(:"#{field}=", (attrs[field] || attrs[field.to_s]).presence)
168
+ end
169
+ # ONLY when the form carried it. Two separate cards post to this method,
170
+ # and an absent checkbox means "this form does not manage the logo", not
171
+ # "show the logo" — writing false either way let saving the subject
172
+ # silently un-hide a logo the operator had hidden.
173
+ if attrs.key?(:hide_logo) || attrs.key?("hide_logo")
174
+ record.hide_logo = ActiveModel::Type::Boolean.new.cast(attrs[:hide_logo] || attrs["hide_logo"]) || false
175
+ end
176
+ record.save!
177
+ # Drop the memo, or the operator is shown the value they just replaced —
178
+ # the same "saved successfully, changed nothing" shape the permit bug had.
179
+ forget!(key)
180
+ record
181
+ end
182
+
183
+ # Store a percent, or clear the override with nil/blank so the email falls
184
+ # back to the registry default rather than being pinned to whatever the
185
+ # default happened to be on the day.
186
+ def set_scrim(key, percent)
187
+ return nil unless table_ready?
188
+
189
+ record = find_or_initialize_by(email_key: key.to_s)
190
+ record.scrim_percent = percent.presence&.to_i
191
+ record.save!
192
+ forget!(key)
193
+ record
194
+ end
195
+
196
+ # Reference the constant directly so Zeitwerk autoloads it — defined?()
197
+ # does NOT trigger autoload, so it reads "undefined" for a not-yet-loaded
198
+ # const and would silently disable every setting.
199
+ def table_ready?
200
+ table_exists?
201
+ rescue ActiveRecord::ActiveRecordError, NameError
202
+ false
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,191 @@
1
+ module Studio
2
+ # A LAYERED email banner: a background image with the header, sub-text and
3
+ # logo sitting on top as live HTML, rather than composed into the picture.
4
+ #
5
+ # ## Why layered rather than composited
6
+ #
7
+ # The alternative is drawing the text into the image server-side. That gives
8
+ # pixel-exact brand typography in every client, but it cannot do the one thing
9
+ # this design needs: an ANIMATED background WITH per-recipient text. Composing
10
+ # "Welcome Mason!" into sixty frames means a multi-megabyte GIF generated per
11
+ # recipient, per send.
12
+ #
13
+ # Layering separates them. The background animates, the greeting is live, and
14
+ # nothing is generated at send time.
15
+ #
16
+ # ## What it costs, stated plainly
17
+ #
18
+ # Gmail and Outlook strip webfonts, so the heading falls back to a system face
19
+ # rather than the brand font. In exchange the text survives blocked images
20
+ # (which Outlook desktop does by default), stays selectable and translatable,
21
+ # and no asset is produced per send.
22
+ #
23
+ # ## Why the markup looks like 1999
24
+ #
25
+ # Outlook on Windows renders through Word, which ignores `background-image` on
26
+ # nearly everything. The `<td background>` attribute plus a VML `v:rect` /
27
+ # `v:fill` block — the long-established "bulletproof background" pattern — is
28
+ # what makes a background image work there at all. The conditional comment is
29
+ # invisible to every other client.
30
+ # A plain class, not a Struct-with-block: constants declared inside a
31
+ # `Struct.new do ... end` attach to the ENCLOSING module, so DEFAULT_SCRIM
32
+ # would have been Studio::DEFAULT_SCRIM and Banner::DEFAULT_SCRIM would not
33
+ # exist. Named constants are part of this object's API, so they live on it.
34
+ class Banner
35
+ ATTRIBUTES = %i[background_url header subtext logo_url logo_alt scrim width height].freeze
36
+ attr_reader(*ATTRIBUTES)
37
+
38
+ def initialize(**attrs)
39
+ unknown = attrs.keys - ATTRIBUTES
40
+ raise ArgumentError, "unknown banner attribute: #{unknown.join(", ")}" if unknown.any?
41
+
42
+ ATTRIBUTES.each { |name| instance_variable_set(:"@#{name}", attrs[name]) }
43
+ end
44
+
45
+ # The email card is 600px wide; the banner fills it.
46
+ #
47
+ # 300, not 200. It was cut to 200 to take out vertical dead space, which the
48
+ # proportional type below then closed on its own — so the shorter box was
49
+ # buying nothing and costing the artwork half its sky. Everything in the
50
+ # partial scales from this number, which is what makes the change one line.
51
+ DEFAULT_WIDTH = 600
52
+ DEFAULT_HEIGHT = 300
53
+
54
+ # A wash between the artwork and the text. Not decoration: background art is
55
+ # chosen for looks, not contrast, and white text over a pale sky is
56
+ # unreadable. 0 disables it for artwork already dark enough to carry type.
57
+ #
58
+ # Raised from 0.34 after seeing it in a real inbox — bright artwork left the
59
+ # sub-text working harder than it should. Rendered at 0.34 / 0.45 / 0.55 and
60
+ # chosen by eye, because "legible" is a judgement about a picture, not a
61
+ # number a test can settle.
62
+ DEFAULT_SCRIM = 0.40
63
+
64
+ def width = (@width || DEFAULT_WIDTH).to_i
65
+ def height = (@height || DEFAULT_HEIGHT).to_i
66
+
67
+ # The scrim as a SOLID hex, for Outlook.
68
+ #
69
+ # Word's rendering engine ignores rgba(), so the wash simply does not exist
70
+ # there — white text over bare artwork, which is the exact contrast case the
71
+ # scrim was added to solve, in the one client nobody can spot-check. VML
72
+ # cannot layer a translucent fill over an image fill either, so the honest
73
+ # approximation is a solid colour: the scrim tint blended toward the artwork's
74
+ # own darkness by the same fraction. It is not the same picture as everywhere
75
+ # else, and it is legible, which is the point.
76
+ SCRIM_RGB = [24, 16, 64].freeze
77
+
78
+ def scrim_solid_hex
79
+ fraction = scrim_opacity
80
+ # Blend the tint toward mid-grey rather than to black: at low opacities a
81
+ # blend toward black reads far darker in Outlook than the rgba() wash does
82
+ # elsewhere, which trades one wrong picture for another.
83
+ blended = SCRIM_RGB.map { |channel| ((channel * fraction) + (128 * (1 - fraction))).round.clamp(0, 255) }
84
+ format("#%02X%02X%02X", *blended)
85
+ end
86
+
87
+ def scrim_opacity
88
+ value = @scrim
89
+ return DEFAULT_SCRIM if value.nil?
90
+
91
+ value.to_f.clamp(0.0, 1.0)
92
+ end
93
+
94
+ # A banner with nothing to show is not a banner. The layout falls back to
95
+ # the plain <img> path (or to no banner at all).
96
+ def renderable? = background_url.present? || header.present?
97
+
98
+ # Everything the layout needs for one email, or nil.
99
+ #
100
+ # Reads the catalogue for the artwork so an app inherits the shared
101
+ # background and logo without repeating them, and lets a caller override any
102
+ # piece per send — which is the whole point of the header being dynamic.
103
+ # `name` is the DYNAMIC part and the only thing a mailer should normally
104
+ # pass. A mailer that hands over a finished header instead takes the wording
105
+ # away from the operator: the /admin/emails field would still accept an edit
106
+ # and the email would still ignore it — a control that lies about what it
107
+ # does. So the mailer supplies who the person is, and the operator supplies
108
+ # what the banner says about them.
109
+ def self.for(key, name: nil, header: nil, subtext: nil, background_url: nil,
110
+ logo_url: nil, scrim: nil, logo_alt: nil)
111
+ banner = new(
112
+ background_url: background_url || Studio::EmailCatalog.background_url(key),
113
+ logo_url: logo_url || safe_image_url(Studio::EmailCatalog.resolved_logo_url(key)),
114
+ logo_alt: logo_alt || Studio.app_name,
115
+ header: header || header_for(key, name),
116
+ subtext: subtext || Studio::EmailCatalog.subtext(key),
117
+ # Resolution order: an explicit argument (a caller who knows better),
118
+ # then what the OPERATOR saved on /admin/emails, then the registry, then
119
+ # the default. The operator sits above the registry on purpose — they
120
+ # are the one looking at the artwork.
121
+ scrim: scrim || Studio::EmailCatalog.scrim(key)
122
+ )
123
+ # A LAYERED banner needs its picture, and renderable? deliberately accepts
124
+ # a header alone — right for a caller building a Banner directly, wrong
125
+ # here. A nil background means the catalogue said this app sends the email
126
+ # flat, and a text-only card is not what that inbox gets.
127
+ banner.background_url.present? ? banner : nil
128
+ end
129
+
130
+ # The placeholder an operator types into the header field. Braces rather
131
+ # than Ruby's %{name}: an operator-editable string is passed to no formatter
132
+ # here, and a stray "%" in "50% off" would raise inside format() where a
133
+ # stray brace is simply left alone.
134
+ # An operator types the logo URL into an admin form, and it is rendered into
135
+ # an <img src>. Admin-only and low risk, but "javascript:" and "data:" in a
136
+ # src are cheap to refuse and there is no reason to carry them: a logo is
137
+ # fetched over http(s) or served from this app's own asset path.
138
+ def self.safe_image_url(url)
139
+ value = url.to_s.strip
140
+ return nil if value.empty?
141
+ return value if value.start_with?("/")
142
+
143
+ value.match?(%r{\Ahttps?://}i) ? value : nil
144
+ end
145
+
146
+ NAME_PLACEHOLDER = "{name}".freeze
147
+
148
+ # The app's own name. Present because the DEFAULTS need it: "Your sign-in
149
+ # link" reads as though it could be from anyone, and a registry constant
150
+ # cannot interpolate Studio.app_name at load time. An operator gets it for
151
+ # free in any field.
152
+ APP_PLACEHOLDER = "{app}".freeze
153
+
154
+ # FIRST name only. The banner is one line of large type in a 600px box, and
155
+ # "Welcome Bartholomew Fitzgerald-Montgomery!" wraps out of it.
156
+ # Shared with the SUBJECT, which takes the same placeholder. One
157
+ # implementation, so "{name}" cannot mean two things on one email.
158
+ def self.interpolate(template, name)
159
+ text = template.to_s.gsub(APP_PLACEHOLDER, Studio.app_name.to_s)
160
+ first = name.to_s.strip.split.first
161
+ return text.gsub(NAME_PLACEHOLDER, first) if first.present?
162
+
163
+ # NO NAME, AND NO RAW PLACEHOLDER EITHER. The header carries a whole second
164
+ # field for this case; a subject line does not, and "Sign in to Studio,
165
+ # {name}" reaching an inbox is the most visible way this feature fails. The
166
+ # token goes, and the punctuation it hung off goes with it — the result is
167
+ # "Sign in to Studio", not "Sign in to Studio, ".
168
+ # Two passes, because the punctuation can sit on either side: "Sign in,
169
+ # {name}" and "{name}, your link is here" both have to come out clean, and
170
+ # "Hi {name} welcome" must not become "Hiwelcome".
171
+ text.gsub(/[,;:\u2014-]?\s*#{Regexp.escape(NAME_PLACEHOLDER)}/, "")
172
+ .sub(/\A\s*[,;:\u2014-]\s*/, "")
173
+ .squeeze(" ").strip
174
+ end
175
+
176
+ def self.header_for(key, name)
177
+ template = Studio::EmailCatalog.header_template(key).to_s
178
+ first = name.to_s.strip.split.first
179
+
180
+ return interpolate(template, name) if first.present?
181
+ # No name: a template that asks for one cannot be rendered honestly, so
182
+ # the fallback answers instead. A template with no name placeholder is
183
+ # already name-free and stands as written — still interpolated, because
184
+ # {app} does not depend on the recipient.
185
+ fallback = Studio::EmailCatalog.header_fallback(key) if template.include?(NAME_PLACEHOLDER)
186
+
187
+ interpolate(fallback || template, nil)
188
+ end
189
+ end
190
+
191
+ end