studio-engine 0.40.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 +116 -0
- data/Gemfile +6 -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
- data/studio-engine.gemspec +5 -1
- metadata +20 -2
|
@@ -0,0 +1,186 @@
|
|
|
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
|
+
DEFAULT_WIDTH = 600
|
|
47
|
+
DEFAULT_HEIGHT = 200
|
|
48
|
+
|
|
49
|
+
# A wash between the artwork and the text. Not decoration: background art is
|
|
50
|
+
# chosen for looks, not contrast, and white text over a pale sky is
|
|
51
|
+
# unreadable. 0 disables it for artwork already dark enough to carry type.
|
|
52
|
+
#
|
|
53
|
+
# Raised from 0.34 after seeing it in a real inbox — bright artwork left the
|
|
54
|
+
# sub-text working harder than it should. Rendered at 0.34 / 0.45 / 0.55 and
|
|
55
|
+
# chosen by eye, because "legible" is a judgement about a picture, not a
|
|
56
|
+
# number a test can settle.
|
|
57
|
+
DEFAULT_SCRIM = 0.40
|
|
58
|
+
|
|
59
|
+
def width = (@width || DEFAULT_WIDTH).to_i
|
|
60
|
+
def height = (@height || DEFAULT_HEIGHT).to_i
|
|
61
|
+
|
|
62
|
+
# The scrim as a SOLID hex, for Outlook.
|
|
63
|
+
#
|
|
64
|
+
# Word's rendering engine ignores rgba(), so the wash simply does not exist
|
|
65
|
+
# there — white text over bare artwork, which is the exact contrast case the
|
|
66
|
+
# scrim was added to solve, in the one client nobody can spot-check. VML
|
|
67
|
+
# cannot layer a translucent fill over an image fill either, so the honest
|
|
68
|
+
# approximation is a solid colour: the scrim tint blended toward the artwork's
|
|
69
|
+
# own darkness by the same fraction. It is not the same picture as everywhere
|
|
70
|
+
# else, and it is legible, which is the point.
|
|
71
|
+
SCRIM_RGB = [24, 16, 64].freeze
|
|
72
|
+
|
|
73
|
+
def scrim_solid_hex
|
|
74
|
+
fraction = scrim_opacity
|
|
75
|
+
# Blend the tint toward mid-grey rather than to black: at low opacities a
|
|
76
|
+
# blend toward black reads far darker in Outlook than the rgba() wash does
|
|
77
|
+
# elsewhere, which trades one wrong picture for another.
|
|
78
|
+
blended = SCRIM_RGB.map { |channel| ((channel * fraction) + (128 * (1 - fraction))).round.clamp(0, 255) }
|
|
79
|
+
format("#%02X%02X%02X", *blended)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def scrim_opacity
|
|
83
|
+
value = @scrim
|
|
84
|
+
return DEFAULT_SCRIM if value.nil?
|
|
85
|
+
|
|
86
|
+
value.to_f.clamp(0.0, 1.0)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A banner with nothing to show is not a banner. The layout falls back to
|
|
90
|
+
# the plain <img> path (or to no banner at all).
|
|
91
|
+
def renderable? = background_url.present? || header.present?
|
|
92
|
+
|
|
93
|
+
# Everything the layout needs for one email, or nil.
|
|
94
|
+
#
|
|
95
|
+
# Reads the catalogue for the artwork so an app inherits the shared
|
|
96
|
+
# background and logo without repeating them, and lets a caller override any
|
|
97
|
+
# piece per send — which is the whole point of the header being dynamic.
|
|
98
|
+
# `name` is the DYNAMIC part and the only thing a mailer should normally
|
|
99
|
+
# pass. A mailer that hands over a finished header instead takes the wording
|
|
100
|
+
# away from the operator: the /admin/emails field would still accept an edit
|
|
101
|
+
# and the email would still ignore it — a control that lies about what it
|
|
102
|
+
# does. So the mailer supplies who the person is, and the operator supplies
|
|
103
|
+
# what the banner says about them.
|
|
104
|
+
def self.for(key, name: nil, header: nil, subtext: nil, background_url: nil,
|
|
105
|
+
logo_url: nil, scrim: nil, logo_alt: nil)
|
|
106
|
+
banner = new(
|
|
107
|
+
background_url: background_url || Studio::EmailCatalog.background_url(key),
|
|
108
|
+
logo_url: logo_url || safe_image_url(Studio::EmailCatalog.resolved_logo_url(key)),
|
|
109
|
+
logo_alt: logo_alt || Studio.app_name,
|
|
110
|
+
header: header || header_for(key, name),
|
|
111
|
+
subtext: subtext || Studio::EmailCatalog.subtext(key),
|
|
112
|
+
# Resolution order: an explicit argument (a caller who knows better),
|
|
113
|
+
# then what the OPERATOR saved on /admin/emails, then the registry, then
|
|
114
|
+
# the default. The operator sits above the registry on purpose — they
|
|
115
|
+
# are the one looking at the artwork.
|
|
116
|
+
scrim: scrim || Studio::EmailCatalog.scrim(key)
|
|
117
|
+
)
|
|
118
|
+
# A LAYERED banner needs its picture, and renderable? deliberately accepts
|
|
119
|
+
# a header alone — right for a caller building a Banner directly, wrong
|
|
120
|
+
# here. A nil background means the catalogue said this app sends the email
|
|
121
|
+
# flat, and a text-only card is not what that inbox gets.
|
|
122
|
+
banner.background_url.present? ? banner : nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The placeholder an operator types into the header field. Braces rather
|
|
126
|
+
# than Ruby's %{name}: an operator-editable string is passed to no formatter
|
|
127
|
+
# here, and a stray "%" in "50% off" would raise inside format() where a
|
|
128
|
+
# stray brace is simply left alone.
|
|
129
|
+
# An operator types the logo URL into an admin form, and it is rendered into
|
|
130
|
+
# an <img src>. Admin-only and low risk, but "javascript:" and "data:" in a
|
|
131
|
+
# src are cheap to refuse and there is no reason to carry them: a logo is
|
|
132
|
+
# fetched over http(s) or served from this app's own asset path.
|
|
133
|
+
def self.safe_image_url(url)
|
|
134
|
+
value = url.to_s.strip
|
|
135
|
+
return nil if value.empty?
|
|
136
|
+
return value if value.start_with?("/")
|
|
137
|
+
|
|
138
|
+
value.match?(%r{\Ahttps?://}i) ? value : nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
NAME_PLACEHOLDER = "{name}".freeze
|
|
142
|
+
|
|
143
|
+
# The app's own name. Present because the DEFAULTS need it: "Your sign-in
|
|
144
|
+
# link" reads as though it could be from anyone, and a registry constant
|
|
145
|
+
# cannot interpolate Studio.app_name at load time. An operator gets it for
|
|
146
|
+
# free in any field.
|
|
147
|
+
APP_PLACEHOLDER = "{app}".freeze
|
|
148
|
+
|
|
149
|
+
# FIRST name only. The banner is one line of large type in a 600px box, and
|
|
150
|
+
# "Welcome Bartholomew Fitzgerald-Montgomery!" wraps out of it.
|
|
151
|
+
# Shared with the SUBJECT, which takes the same placeholder. One
|
|
152
|
+
# implementation, so "{name}" cannot mean two things on one email.
|
|
153
|
+
def self.interpolate(template, name)
|
|
154
|
+
text = template.to_s.gsub(APP_PLACEHOLDER, Studio.app_name.to_s)
|
|
155
|
+
first = name.to_s.strip.split.first
|
|
156
|
+
return text.gsub(NAME_PLACEHOLDER, first) if first.present?
|
|
157
|
+
|
|
158
|
+
# NO NAME, AND NO RAW PLACEHOLDER EITHER. The header carries a whole second
|
|
159
|
+
# field for this case; a subject line does not, and "Sign in to Studio,
|
|
160
|
+
# {name}" reaching an inbox is the most visible way this feature fails. The
|
|
161
|
+
# token goes, and the punctuation it hung off goes with it — the result is
|
|
162
|
+
# "Sign in to Studio", not "Sign in to Studio, ".
|
|
163
|
+
# Two passes, because the punctuation can sit on either side: "Sign in,
|
|
164
|
+
# {name}" and "{name}, your link is here" both have to come out clean, and
|
|
165
|
+
# "Hi {name} welcome" must not become "Hiwelcome".
|
|
166
|
+
text.gsub(/[,;:\u2014-]?\s*#{Regexp.escape(NAME_PLACEHOLDER)}/, "")
|
|
167
|
+
.sub(/\A\s*[,;:\u2014-]\s*/, "")
|
|
168
|
+
.squeeze(" ").strip
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.header_for(key, name)
|
|
172
|
+
template = Studio::EmailCatalog.header_template(key).to_s
|
|
173
|
+
first = name.to_s.strip.split.first
|
|
174
|
+
|
|
175
|
+
return interpolate(template, name) if first.present?
|
|
176
|
+
# No name: a template that asks for one cannot be rendered honestly, so
|
|
177
|
+
# the fallback answers instead. A template with no name placeholder is
|
|
178
|
+
# already name-free and stands as written — still interpolated, because
|
|
179
|
+
# {app} does not depend on the recipient.
|
|
180
|
+
fallback = Studio::EmailCatalog.header_fallback(key) if template.include?(NAME_PLACEHOLDER)
|
|
181
|
+
|
|
182
|
+
interpolate(fallback || template, nil)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
end
|
|
@@ -61,6 +61,12 @@ module Studio
|
|
|
61
61
|
module EmailCatalog
|
|
62
62
|
PURPOSE = "email_banner".freeze
|
|
63
63
|
|
|
64
|
+
# The LOGO is a separate purpose, not a variant of the banner. They are
|
|
65
|
+
# different pictures with different rules: a banner is 3:1 artwork that may be
|
|
66
|
+
# an animated GIF, a logo is a small transparent mark. Sharing one purpose
|
|
67
|
+
# would make "revert the banner" and "revert the logo" the same row.
|
|
68
|
+
LOGO_PURPOSE = "email_logo".freeze
|
|
69
|
+
|
|
64
70
|
# What an email is FOR. Transactional = sent in response to something the
|
|
65
71
|
# recipient did; marketing = sent because we decided to. Kept because
|
|
66
72
|
# turf-monster's catalog carried it and the distinction drives real policy
|
|
@@ -74,7 +80,9 @@ module Studio
|
|
|
74
80
|
# type — :transactional or :marketing.
|
|
75
81
|
# preview — callable returning a Mail, or nil.
|
|
76
82
|
Entry = Struct.new(:key, :label, :description, :default_asset, :type, :preview,
|
|
77
|
-
:default_origin, :aspect_ratio,
|
|
83
|
+
:default_origin, :aspect_ratio, :background, :logo, :scrim,
|
|
84
|
+
:header, :header_fallback, :subtext, :subject,
|
|
85
|
+
keyword_init: true) do
|
|
78
86
|
def to_s = key
|
|
79
87
|
def previewable? = preview.respond_to?(:call)
|
|
80
88
|
# nil-safe: an Entry built directly (the STANDARD seed) may carry no type.
|
|
@@ -103,20 +111,50 @@ module Studio
|
|
|
103
111
|
label: "Magic-link sign-in",
|
|
104
112
|
description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
|
|
105
113
|
default_asset: "emails/magic-link.gif",
|
|
106
|
-
aspect_ratio: 3.0
|
|
114
|
+
aspect_ratio: 3.0,
|
|
115
|
+
# Layered artwork: the background animates, the greeting is live HTML on
|
|
116
|
+
# top. default_asset above stays the flat <img> for a mailer that has not
|
|
117
|
+
# adopted the layered banner.
|
|
118
|
+
background: "emails/magic-link-background.gif",
|
|
119
|
+
logo: "emails/logo-horizontal.png",
|
|
120
|
+
# The DEFAULT wording, overridable per app on /admin/emails. {name} is
|
|
121
|
+
# filled from whoever the mailer says the recipient is.
|
|
122
|
+
header: "Welcome {name}!",
|
|
123
|
+
header_fallback: "Your Magic Link",
|
|
124
|
+
subtext: "your sign-in link is below",
|
|
125
|
+
subject: "Your {app} sign-in link"
|
|
107
126
|
},
|
|
108
127
|
{
|
|
109
|
-
key: "
|
|
110
|
-
label: "
|
|
111
|
-
description: "
|
|
112
|
-
|
|
113
|
-
|
|
128
|
+
key: "newsletter_subscribed",
|
|
129
|
+
label: "Newsletter subscribed",
|
|
130
|
+
description: "Welcomes someone who has just joined the mailing list.",
|
|
131
|
+
aspect_ratio: 3.0,
|
|
132
|
+
# LAYERED-NATIVE: no default_asset. A flat asset is the pre-layered
|
|
133
|
+
# fallback — artwork with the words baked in, for a mailer that only
|
|
134
|
+
# knows how to render an <img>. Studio::NewsletterMailer has known how to
|
|
135
|
+
# layer since the day it was written, so a baked-in copy of the same
|
|
136
|
+
# picture would be a second thing to keep in sync and never be shown.
|
|
137
|
+
background: "emails/newsletter-subscribed-background.gif",
|
|
138
|
+
logo: "emails/logo-horizontal.png",
|
|
139
|
+
header: "Welcome {name}!",
|
|
140
|
+
header_fallback: "You're subscribed!",
|
|
141
|
+
subtext: "you're on the list",
|
|
142
|
+
subject: "You're subscribed to {app}",
|
|
143
|
+
# The engine ships this email's preview because it can: the mailer takes
|
|
144
|
+
# a bare address, so no host sample data is involved. Every other entry's
|
|
145
|
+
# builder needs records only the host has — this one does not, and an
|
|
146
|
+
# inherited email with no preview is a row on every app's manager that
|
|
147
|
+
# cannot be looked at.
|
|
148
|
+
preview: -> { Studio::NewsletterMailer.subscribed("preview@example.com", name: "Alex") }
|
|
114
149
|
}
|
|
115
150
|
].freeze
|
|
116
151
|
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
#
|
|
152
|
+
# The FALLBACK shape, for an email that states none. 2:1 because that is what
|
|
153
|
+
# turf-monster's eight banners are, and changing it would recrop all of them.
|
|
154
|
+
#
|
|
155
|
+
# It is NOT what the engine's own emails use: both STANDARD entries declare
|
|
156
|
+
# aspect_ratio: 3.0 and both shipped backgrounds are 1200x400. The ratio is
|
|
157
|
+
# per-entry precisely so those two answers can differ.
|
|
120
158
|
ASPECT_RATIO = 2.0
|
|
121
159
|
MAX_WIDTH = 1200
|
|
122
160
|
|
|
@@ -130,7 +168,8 @@ module Studio
|
|
|
130
168
|
# existing value — that is what lets a host relabel an inherited email, or
|
|
131
169
|
# attach a preview builder to it, without restating its artwork.
|
|
132
170
|
def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil,
|
|
133
|
-
aspect_ratio: nil
|
|
171
|
+
aspect_ratio: nil, background: nil, logo: nil, scrim: nil,
|
|
172
|
+
header: nil, header_fallback: nil, subtext: nil, subject: nil)
|
|
134
173
|
key = key.to_s
|
|
135
174
|
existing = registry[key]
|
|
136
175
|
registry[key] = Entry.new(
|
|
@@ -145,7 +184,14 @@ module Studio
|
|
|
145
184
|
# had, so a host relabelling an inherited email does not accidentally
|
|
146
185
|
# claim the engine's picture as its own.
|
|
147
186
|
default_origin: default_asset.nil? ? (existing&.default_origin || :engine) : :app,
|
|
148
|
-
aspect_ratio: aspect_ratio || existing&.aspect_ratio
|
|
187
|
+
aspect_ratio: aspect_ratio || existing&.aspect_ratio,
|
|
188
|
+
background: background.nil? ? existing&.background : background.presence,
|
|
189
|
+
logo: logo.nil? ? existing&.logo : logo.presence,
|
|
190
|
+
scrim: scrim.nil? ? existing&.scrim : scrim,
|
|
191
|
+
header: header.nil? ? existing&.header : header.presence,
|
|
192
|
+
header_fallback: header_fallback.nil? ? existing&.header_fallback : header_fallback.presence,
|
|
193
|
+
subtext: subtext.nil? ? existing&.subtext : subtext.presence,
|
|
194
|
+
subject: subject.nil? ? existing&.subject : subject.presence
|
|
149
195
|
)
|
|
150
196
|
key
|
|
151
197
|
end
|
|
@@ -202,7 +248,9 @@ module Studio
|
|
|
202
248
|
@registry ||= STANDARD.each_with_object({}) do |attrs, out|
|
|
203
249
|
out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]),
|
|
204
250
|
preview: attrs[:preview], default_origin: :engine,
|
|
205
|
-
aspect_ratio: attrs[:aspect_ratio]
|
|
251
|
+
aspect_ratio: attrs[:aspect_ratio],
|
|
252
|
+
background: attrs[:background], logo: attrs[:logo],
|
|
253
|
+
scrim: attrs[:scrim])
|
|
206
254
|
end
|
|
207
255
|
end
|
|
208
256
|
|
|
@@ -239,6 +287,110 @@ module Studio
|
|
|
239
287
|
# This email's banner shape, falling back to the shared default.
|
|
240
288
|
def ratio(key) = entry(key)&.ratio || ASPECT_RATIO
|
|
241
289
|
|
|
290
|
+
# --- layered banner artwork ---------------------------------------------
|
|
291
|
+
#
|
|
292
|
+
# Absolute URLs, because a mail client fetches these from an inbox and a
|
|
293
|
+
# root-relative path resolves against nothing there.
|
|
294
|
+
|
|
295
|
+
# Saved by the operator > registered by the app > engine default.
|
|
296
|
+
def scrim(key)
|
|
297
|
+
Studio::EmailSetting.scrim_for(key) || entry(key)&.scrim
|
|
298
|
+
rescue StandardError
|
|
299
|
+
entry(key)&.scrim
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# --- the banner's words -------------------------------------------------
|
|
303
|
+
#
|
|
304
|
+
# Same order as the tint, for the same reason: the operator is the one
|
|
305
|
+
# looking at the artwork. Each falls back to the registry, then to a
|
|
306
|
+
# sensible default, so an email that nobody has configured still reads.
|
|
307
|
+
|
|
308
|
+
# The header TEMPLATE — it may contain {name}. Interpolation happens in
|
|
309
|
+
# Studio::Banner, which is the only place that knows the recipient.
|
|
310
|
+
def header_template(key)
|
|
311
|
+
saved(key, :header) || entry(key)&.header || entry(key)&.label
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# What the header says when no name is known. A magic link is often the
|
|
315
|
+
# first contact we have with someone, so "Welcome {name}!" must have
|
|
316
|
+
# somewhere to land that is not "Welcome !".
|
|
317
|
+
def header_fallback(key)
|
|
318
|
+
saved(key, :header_fallback) || entry(key)&.header_fallback || entry(key)&.label
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def subtext(key)
|
|
322
|
+
saved(key, :subtext) || entry(key)&.subtext
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# The subject line, resolved the same way and supporting the same {name}
|
|
326
|
+
# placeholder. A mailer calls this instead of hard-coding a string, which is
|
|
327
|
+
# what makes the field on /admin/emails real rather than decorative.
|
|
328
|
+
def subject_for(key, name: nil)
|
|
329
|
+
template = saved(key, :subject) || entry(key)&.subject
|
|
330
|
+
return nil if template.blank?
|
|
331
|
+
|
|
332
|
+
Studio::Banner.interpolate(template, name).presence
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# nil when the operator has hidden the logo — distinct from "none saved",
|
|
336
|
+
# which inherits the registry's.
|
|
337
|
+
# Hidden > uploaded here > a URL the operator typed > the registry's.
|
|
338
|
+
# "Hidden" comes first because it is the one answer the others cannot express.
|
|
339
|
+
def resolved_logo_url(key)
|
|
340
|
+
return nil if Studio::EmailSetting.hide_logo?(key)
|
|
341
|
+
|
|
342
|
+
uploaded_logo_url(key) || saved(key, :logo_url) || logo_url(key)
|
|
343
|
+
rescue StandardError
|
|
344
|
+
logo_url(key)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# An operator-saved field, or nil. Rescues because these are read on a
|
|
348
|
+
# delivery path: a settings table that is missing, locked, or mid-migration
|
|
349
|
+
# must degrade to the registry default rather than fail the send.
|
|
350
|
+
def saved(key, field)
|
|
351
|
+
Studio::EmailSetting.copy_for(key, field)
|
|
352
|
+
rescue StandardError
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def scrim_percent(key)
|
|
357
|
+
value = scrim(key) || Studio::Banner::DEFAULT_SCRIM
|
|
358
|
+
(value.to_f * 100).round
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# THE APP'S OWN UPLOAD WINS, then the registered artwork. Same two layers as
|
|
362
|
+
# resolved_url, and for the same reason: uploading on /admin/emails is how an
|
|
363
|
+
# operator says "this picture is ours now".
|
|
364
|
+
#
|
|
365
|
+
# Reading only the registry made the Upload button a control that lies on a
|
|
366
|
+
# LAYERED email — the upload landed, the page showed it, the provenance badge
|
|
367
|
+
# flipped to "Uploaded here", and the email kept sending the gem's artwork
|
|
368
|
+
# because the layered banner never looked at the row.
|
|
369
|
+
#
|
|
370
|
+
# NIL WHEN THIS APP OWNS THE ARTWORK. A host registering its own flat
|
|
371
|
+
# default_asset sends that picture; the background it also inherited is the
|
|
372
|
+
# engine's and nothing sends it. The list row carried this guard alone, so
|
|
373
|
+
# the detail page still layered live text over artwork no inbox receives.
|
|
374
|
+
def background_url(key)
|
|
375
|
+
return nil unless entry(key)&.engine_artwork?
|
|
376
|
+
|
|
377
|
+
url(key) || absolute_asset_url(entry(key)&.background)
|
|
378
|
+
end
|
|
379
|
+
def logo_url(key) = absolute_asset_url(entry(key)&.logo)
|
|
380
|
+
|
|
381
|
+
def absolute_asset_url(asset)
|
|
382
|
+
return nil if asset.blank?
|
|
383
|
+
|
|
384
|
+
path = ActionController::Base.helpers.asset_path(asset)
|
|
385
|
+
return nil if path.blank?
|
|
386
|
+
return path if path.start_with?("http")
|
|
387
|
+
|
|
388
|
+
host = mailer_asset_host
|
|
389
|
+
host ? "#{host}#{path}" : path
|
|
390
|
+
rescue StandardError
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
242
394
|
def app_owned?(key) = source(key) == :app
|
|
243
395
|
|
|
244
396
|
# --- Preview -----------------------------------------------------------
|
|
@@ -348,7 +500,31 @@ module Studio
|
|
|
348
500
|
# whatever host and port this app is being viewed on (an absolute mailer
|
|
349
501
|
# asset_host is set for the inbox, not for a browser on localhost:3042).
|
|
350
502
|
def preview_url(key)
|
|
351
|
-
url(key) ||
|
|
503
|
+
url(key) || preview_asset_path(key)
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# What the manager DRAWS, which is a different question from what the flat
|
|
507
|
+
# <img> fallback sends — so it resolves in the opposite order.
|
|
508
|
+
#
|
|
509
|
+
# LAYERED FIRST. magic_link ships both: `emails/magic-link.gif`, the old
|
|
510
|
+
# banner with "Your Magic Link" baked into the picture, and
|
|
511
|
+
# `emails/magic-link-background.gif`, the artwork the layered banner draws
|
|
512
|
+
# live text on top of. A mailer that has adopted layering sends the SECOND
|
|
513
|
+
# one — so previewing the first showed the operator a picture no inbox
|
|
514
|
+
# receives, and did it convincingly, because baked-in words look like a real
|
|
515
|
+
# banner. Same failure as the "No image" badge, one door further along: the
|
|
516
|
+
# page answering from the field it happened to read instead of from what
|
|
517
|
+
# ships.
|
|
518
|
+
#
|
|
519
|
+
# The flat asset stays the fallback, for a host still on the engine's own
|
|
520
|
+
# unlayered UserMailer — there, the baked-text banner IS what arrives.
|
|
521
|
+
#
|
|
522
|
+
# Unless THIS APP owns the artwork, in which case it previews exactly what
|
|
523
|
+
# the flat resolution sends — same method, so the two cannot disagree.
|
|
524
|
+
def preview_asset_path(key)
|
|
525
|
+
return default_asset_path(key) unless entry(key)&.engine_artwork?
|
|
526
|
+
|
|
527
|
+
asset_path(entry(key)&.background.presence || entry(key)&.default_asset)
|
|
352
528
|
end
|
|
353
529
|
|
|
354
530
|
# The ImageCache row holding this app's override, or nil (nothing uploaded /
|
|
@@ -359,14 +535,60 @@ module Studio
|
|
|
359
535
|
::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
|
|
360
536
|
end
|
|
361
537
|
|
|
362
|
-
|
|
363
|
-
|
|
538
|
+
def logo_record(key)
|
|
539
|
+
return nil unless table_ready?
|
|
540
|
+
|
|
541
|
+
::ImageCache.find_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
# An uploaded logo for this email, or nil to inherit.
|
|
545
|
+
def uploaded_logo_url(key)
|
|
546
|
+
logo_record(key)&.url
|
|
547
|
+
rescue StandardError
|
|
548
|
+
nil
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def store_logo(key, io:, content_type: nil)
|
|
552
|
+
s3_key = "email_logos/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
|
|
553
|
+
Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
|
|
554
|
+
cache_control: "public, max-age=300")
|
|
555
|
+
record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: LOGO_PURPOSE, variant: key.to_s)
|
|
556
|
+
previous = record.s3_key
|
|
557
|
+
record.update!(s3_key: s3_key)
|
|
558
|
+
delete_object(previous) if previous.present? && previous != s3_key
|
|
559
|
+
record
|
|
560
|
+
rescue StandardError
|
|
561
|
+
delete_object(s3_key)
|
|
562
|
+
raise
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def revert_logo(key)
|
|
566
|
+
row = logo_record(key)
|
|
567
|
+
return false if row.nil?
|
|
568
|
+
|
|
569
|
+
previous = row.s3_key
|
|
570
|
+
row.destroy!
|
|
571
|
+
delete_object(previous) if previous.present?
|
|
572
|
+
true
|
|
573
|
+
end
|
|
574
|
+
|
|
575
|
+
# Root-relative path to the FLAT artwork — what the <img> fallback sends.
|
|
576
|
+
# Flat first, then the layered background as a last resort so a
|
|
577
|
+
# layered-native email (newsletter_subscribed registers no flat asset,
|
|
578
|
+
# because it never renders one) still has something rather than nothing.
|
|
579
|
+
# See preview_asset_path above for why the manager resolves the other way.
|
|
364
580
|
def default_asset_path(key)
|
|
365
|
-
|
|
581
|
+
asset_path(entry(key)&.default_asset.presence || entry(key)&.background)
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# Shared tail of both resolutions: a logical asset name to a root-relative
|
|
585
|
+
# path, or nil when there is no asset or the host's pipeline cannot resolve
|
|
586
|
+
# it. Rescues broadly because a missing asset must degrade to "no image",
|
|
587
|
+
# never take the manager down.
|
|
588
|
+
def asset_path(asset)
|
|
366
589
|
return nil if asset.nil? || asset.empty?
|
|
367
590
|
|
|
368
|
-
|
|
369
|
-
path.presence
|
|
591
|
+
ActionController::Base.helpers.asset_path(asset).presence
|
|
370
592
|
rescue StandardError
|
|
371
593
|
nil
|
|
372
594
|
end
|
|
@@ -477,11 +699,18 @@ module Studio
|
|
|
477
699
|
|
|
478
700
|
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
|
|
479
701
|
|
|
702
|
+
# GIF is listed because animated banners are uploaded whole — they bypass the
|
|
703
|
+
# cropper, which would flatten them to a single PNG frame. Without this branch
|
|
704
|
+
# a GIF was stored under a ".png" key: the object's Content-Type was still
|
|
705
|
+
# image/gif so it played, but the URL said otherwise, and anything that trusts
|
|
706
|
+
# an extension (a CDN, a proxy, a person reading the bucket) was told the
|
|
707
|
+
# wrong thing.
|
|
480
708
|
def ext_for(content_type)
|
|
481
709
|
case content_type.to_s
|
|
482
710
|
when %r{png} then ".png"
|
|
483
711
|
when %r{jpe?g} then ".jpg"
|
|
484
712
|
when %r{webp} then ".webp"
|
|
713
|
+
when %r{gif} then ".gif"
|
|
485
714
|
else ".png"
|
|
486
715
|
end
|
|
487
716
|
end
|