studio-engine 0.36.0 → 0.38.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 +210 -0
- data/README.md +199 -12
- data/app/assets/images/emails/email-change-confirmation.png +0 -0
- data/app/assets/images/emails/magic-link.png +0 -0
- data/app/controllers/studio/email_images_controller.rb +42 -7
- data/app/controllers/studio/emails_controller.rb +129 -0
- data/app/mailers/user_mailer.rb +4 -1
- data/app/models/image_cache.rb +1 -1
- data/app/services/studio/email_catalog.rb +450 -0
- data/app/services/studio/email_image.rb +57 -74
- data/app/views/layouts/branded_mailer.html.erb +1 -1
- data/app/views/studio/email_images/index.html.erb +30 -1
- data/app/views/studio/emails/_row.html.erb +107 -0
- data/app/views/studio/emails/index.html.erb +116 -0
- data/app/views/studio/emails/show.html.erb +91 -0
- data/app/views/studio/modals/_image_upload.html.erb +38 -7
- data/app/views/studio/modals/_scoped_host.html.erb +195 -0
- data/lib/studio/engine.rb +20 -0
- data/lib/studio/s3.rb +44 -8
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +75 -4
- metadata +10 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
# /admin/emails — the standard transactional-email page every Studio app gets,
|
|
3
|
+
# modelled on the living style guide (/admin/style): a plain host-inherited
|
|
4
|
+
# controller whose view is a bare content wrapper, so it renders inside each
|
|
5
|
+
# host's application layout and picks up that app's navbar and theme.
|
|
6
|
+
#
|
|
7
|
+
# It lists Studio::EmailCatalog's registry — one row per registered email, each
|
|
8
|
+
# showing its live banner and whether that banner is the INHERITED engine
|
|
9
|
+
# default or an APP-OWNED override — and writes an override through the shared
|
|
10
|
+
# crop modal. Replaces /admin/email_images, which now redirects here.
|
|
11
|
+
#
|
|
12
|
+
# An app whose host never set Studio.s3_bucket_prefix cannot store an override.
|
|
13
|
+
# That is a read-only page, not an error: uploads_available? gates the write
|
|
14
|
+
# actions and the view explains why, so the page still shows what each email
|
|
15
|
+
# is currently sending.
|
|
16
|
+
class EmailsController < ApplicationController
|
|
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]
|
|
20
|
+
|
|
21
|
+
MAX_BYTES = 8.megabytes
|
|
22
|
+
|
|
23
|
+
def index
|
|
24
|
+
@entries = Studio::EmailCatalog.entries
|
|
25
|
+
@uploads_available = Studio::EmailCatalog.uploads_available?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# GET /admin/emails/:key — one email: its banner, its type, and a live
|
|
29
|
+
# preview built from the host's sample data.
|
|
30
|
+
def show
|
|
31
|
+
@entry = Studio::EmailCatalog.entry(@key)
|
|
32
|
+
@subject = Studio::EmailCatalog.preview_subject(@key)
|
|
33
|
+
@preview_error = Studio::EmailCatalog.preview_error(@key)
|
|
34
|
+
@uploads_available = Studio::EmailCatalog.uploads_available?
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# GET /admin/emails/:key/raw — the rendered email itself, as the iframe
|
|
38
|
+
# source on #show. Layout-less on purpose: this response IS the email.
|
|
39
|
+
#
|
|
40
|
+
# A preview builder is host code run against whatever sample data this
|
|
41
|
+
# environment happens to hold, so it is expected to fail sometimes. It
|
|
42
|
+
# renders the failure as a readable page inside the iframe rather than
|
|
43
|
+
# 500ing, so one broken builder costs one preview, not the manager.
|
|
44
|
+
def raw
|
|
45
|
+
html = Studio::EmailCatalog.preview_html(@key)
|
|
46
|
+
return render(html: preview_unavailable_html.html_safe, layout: false) if html.nil?
|
|
47
|
+
|
|
48
|
+
render html: html.html_safe, layout: false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# PATCH /admin/emails/:key — upload/replace this app's own banner.
|
|
52
|
+
def update
|
|
53
|
+
file = params[:image]
|
|
54
|
+
unless valid_image?(file)
|
|
55
|
+
message = file.blank? ? "Choose an image to upload." : "Use a PNG, JPG, or WebP under 8 MB."
|
|
56
|
+
return redirect_to admin_emails_path, alert: message, status: :see_other
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
rescue_and_log do
|
|
60
|
+
Studio::EmailCatalog.store(@key, io: file, content_type: file.content_type)
|
|
61
|
+
redirect_to admin_emails_path, notice: "#{Studio::EmailCatalog.label(@key)} banner updated.", status: :see_other
|
|
62
|
+
end
|
|
63
|
+
rescue StandardError
|
|
64
|
+
redirect_to admin_emails_path, alert: "Couldn't save the image. Please try again.", status: :see_other
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# DELETE /admin/emails/:key — drop this app's override and fall back to the
|
|
68
|
+
# inherited default.
|
|
69
|
+
def destroy
|
|
70
|
+
# rescue_and_log, like #update: destroy is a WRITE path (it drops an
|
|
71
|
+
# ImageCache row and deletes the S3 object behind it), and the bare rescue
|
|
72
|
+
# below turns any failure into a friendly alert. Without the log that
|
|
73
|
+
# failure is invisible — the admin sees "try again" and nothing reaches
|
|
74
|
+
# ErrorLog to say why.
|
|
75
|
+
rescue_and_log do
|
|
76
|
+
reverted = Studio::EmailCatalog.revert(@key)
|
|
77
|
+
notice = if reverted
|
|
78
|
+
"#{Studio::EmailCatalog.label(@key)} reverted to the inherited default."
|
|
79
|
+
else
|
|
80
|
+
"#{Studio::EmailCatalog.label(@key)} was already using the inherited default."
|
|
81
|
+
end
|
|
82
|
+
redirect_to admin_emails_path, notice: notice, status: :see_other
|
|
83
|
+
end
|
|
84
|
+
rescue StandardError
|
|
85
|
+
redirect_to admin_emails_path, alert: "Couldn't revert the image. Please try again.", status: :see_other
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def load_entry
|
|
91
|
+
@key = params[:key].to_s
|
|
92
|
+
head :not_found unless Studio::EmailCatalog.known?(@key)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def require_uploads
|
|
96
|
+
return if Studio::EmailCatalog.uploads_available?
|
|
97
|
+
|
|
98
|
+
redirect_to admin_emails_path, status: :see_other,
|
|
99
|
+
alert: "This app has no object storage configured, so email images can't be changed here yet."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Shown INSIDE the preview iframe when the builder is missing or raised.
|
|
103
|
+
# Deliberately plain inline HTML: the iframe is its own document and does not
|
|
104
|
+
# inherit the host app's stylesheet.
|
|
105
|
+
def preview_unavailable_html
|
|
106
|
+
reason = Studio::EmailCatalog.preview_error(@key)
|
|
107
|
+
message = if reason
|
|
108
|
+
"This email's preview builder raised:<br><code style=\"color:#b91c1c\">" \
|
|
109
|
+
"#{ERB::Util.html_escape(reason)}</code>"
|
|
110
|
+
else
|
|
111
|
+
"No preview is registered for this email. Add a <code>preview:</code> " \
|
|
112
|
+
"callable when registering it to see it rendered here."
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
<<~HTML
|
|
116
|
+
<!doctype html>
|
|
117
|
+
<html><body style="margin:0;padding:32px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#334155;background:#f8fafc;">
|
|
118
|
+
<p style="font-size:14px;line-height:1.6;max-width:52ch;">#{message}</p>
|
|
119
|
+
</body></html>
|
|
120
|
+
HTML
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def valid_image?(file)
|
|
124
|
+
file.respond_to?(:content_type) &&
|
|
125
|
+
file.content_type.to_s.start_with?("image/") &&
|
|
126
|
+
file.respond_to?(:size) && file.size.to_i.positive? && file.size <= MAX_BYTES
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/app/mailers/user_mailer.rb
CHANGED
|
@@ -21,7 +21,10 @@ class UserMailer < ApplicationMailer
|
|
|
21
21
|
@app_name = Studio.app_name
|
|
22
22
|
@email = email
|
|
23
23
|
@magic_url = magic_link_url_for(token)
|
|
24
|
-
|
|
24
|
+
# resolved_url, not url: this app's own upload if it has one, otherwise the
|
|
25
|
+
# engine's default banner — which is what makes a brand-new app's sign-in
|
|
26
|
+
# email branded on day one. nil renders bannerless.
|
|
27
|
+
@banner_url = Studio::EmailCatalog.resolved_url(:magic_link)
|
|
25
28
|
@banner_alt = "Your #{@app_name} sign-in link"
|
|
26
29
|
mail(to: email, subject: "Your #{@app_name} sign-in link")
|
|
27
30
|
end
|
data/app/models/image_cache.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
class ImageCache < ApplicationRecord
|
|
2
2
|
# Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
|
|
3
|
-
# Studio::
|
|
3
|
+
# Studio::EmailCatalog stores the admin-managed email banners owner-less. Per-
|
|
4
4
|
# record images (athlete/coach headshots) still set an owner; the
|
|
5
5
|
# variant-uniqueness scope below keeps both shapes distinct.
|
|
6
6
|
belongs_to :owner, polymorphic: true, optional: true
|
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
module Studio
|
|
2
|
+
# The shared email catalog: every email an app sends, what kind it is, how to
|
|
3
|
+
# build a live preview of it, and the banner image it ships with.
|
|
4
|
+
#
|
|
5
|
+
# Named for the prior art it absorbs. turf-monster built ::EmailCatalog +
|
|
6
|
+
# Admin::EmailsController first and left a note on both saying this manager
|
|
7
|
+
# "moves into the shared studio-engine email framework (Phase 2)". This is
|
|
8
|
+
# Phase 2 — so the engine takes the name, the shape (key / name / type /
|
|
9
|
+
# description / preview builder), and the live-preview page, and adds the
|
|
10
|
+
# banner-image half. turf-monster then deletes its copy instead of running two
|
|
11
|
+
# email pages side by side.
|
|
12
|
+
#
|
|
13
|
+
# Was Studio::EmailImage, which is now a delegating shim — see
|
|
14
|
+
# app/services/studio/email_image.rb. The old name outlived its meaning the
|
|
15
|
+
# moment an entry carried a type and a preview builder alongside its image.
|
|
16
|
+
#
|
|
17
|
+
# ## Two layers: inherited default, app-owned override
|
|
18
|
+
#
|
|
19
|
+
# .resolved_url(key) => app's own ImageCache row (its S3 bucket) # app-owned
|
|
20
|
+
# -> the engine's default gem asset # inherited
|
|
21
|
+
# -> nil # no image
|
|
22
|
+
#
|
|
23
|
+
# `.url(key)` stays the PRE-REGISTRY contract — this app's own image or nil —
|
|
24
|
+
# so every caller written before the registry keeps its behavior until its app
|
|
25
|
+
# adopts. See the note on #url; getting this wrong swaps a host's committed
|
|
26
|
+
# artwork for the engine placeholder in live email.
|
|
27
|
+
#
|
|
28
|
+
# Defaults RIDE THE GEM (app/assets/images/emails/*), so a brand-new app with
|
|
29
|
+
# an empty bucket sends good-looking email on day one and needs no cross-app S3
|
|
30
|
+
# permission. Uploading on an app's /admin/emails writes to THAT app's bucket
|
|
31
|
+
# and THAT app's ImageCache row — which is exactly "the asset now belongs to
|
|
32
|
+
# this app". Every app has its own bucket and its own image_caches table, so an
|
|
33
|
+
# override never leaks between apps.
|
|
34
|
+
#
|
|
35
|
+
# ## Registering
|
|
36
|
+
#
|
|
37
|
+
# The engine pre-registers the two every Studio app sends (STANDARD below), so
|
|
38
|
+
# hosts inherit them without declaring anything. A host adds its own workflows
|
|
39
|
+
# from an initializer, mirroring Studio::ModelPage.register:
|
|
40
|
+
#
|
|
41
|
+
# # config/initializers/studio_emails.rb
|
|
42
|
+
# Rails.application.config.to_prepare do
|
|
43
|
+
# Studio::EmailCatalog.register("winnings",
|
|
44
|
+
# label: "Contest winnings",
|
|
45
|
+
# description: "Sent when a player wins a contest.",
|
|
46
|
+
# type: :transactional,
|
|
47
|
+
# preview: -> { ContestMailer.winnings(Entry.where.not(rank: nil).first) })
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
# Re-registering a key updates it in place and keeps its position, so a host
|
|
51
|
+
# can relabel an inherited email without reordering the page.
|
|
52
|
+
#
|
|
53
|
+
# ## Preview
|
|
54
|
+
#
|
|
55
|
+
# `preview` is a callable returning a Mail — the app builds it from whatever
|
|
56
|
+
# sample data it likes. It is what powers the live preview on /admin/emails/:key.
|
|
57
|
+
# It runs ONLY on that admin page, never in a delivery path, and every call is
|
|
58
|
+
# wrapped: an entry whose builder raises shows the error on the page rather
|
|
59
|
+
# than 500ing the manager. An entry without one still lists and still manages
|
|
60
|
+
# its banner; it just has nothing to preview.
|
|
61
|
+
module EmailCatalog
|
|
62
|
+
PURPOSE = "email_banner".freeze
|
|
63
|
+
|
|
64
|
+
# What an email is FOR. Transactional = sent in response to something the
|
|
65
|
+
# recipient did; marketing = sent because we decided to. Kept because
|
|
66
|
+
# turf-monster's catalog carried it and the distinction drives real policy
|
|
67
|
+
# (unsubscribe requirements, send-time rules, which from-address is used).
|
|
68
|
+
TYPES = %i[transactional marketing].freeze
|
|
69
|
+
DEFAULT_TYPE = :transactional
|
|
70
|
+
|
|
71
|
+
# A registered email.
|
|
72
|
+
# default_asset — logical asset path inside the gem (resolved through the
|
|
73
|
+
# host's pipeline); nil means no inherited artwork.
|
|
74
|
+
# type — :transactional or :marketing.
|
|
75
|
+
# preview — callable returning a Mail, or nil.
|
|
76
|
+
Entry = Struct.new(:key, :label, :description, :default_asset, :type, :preview,
|
|
77
|
+
keyword_init: true) do
|
|
78
|
+
def to_s = key
|
|
79
|
+
def previewable? = preview.respond_to?(:call)
|
|
80
|
+
# nil-safe: an Entry built directly (the STANDARD seed) may carry no type.
|
|
81
|
+
def marketing? = type.to_s == "marketing"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The emails EVERY Studio app sends. Pre-registered, so a host inherits both
|
|
85
|
+
# without declaring anything.
|
|
86
|
+
STANDARD = [
|
|
87
|
+
{
|
|
88
|
+
key: "magic_link",
|
|
89
|
+
label: "Magic-link sign-in",
|
|
90
|
+
description: "Passwordless sign-in link. Sent whenever someone asks to sign in by email.",
|
|
91
|
+
default_asset: "emails/magic-link.png"
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "email_change_confirmation",
|
|
95
|
+
label: "Email change confirmation",
|
|
96
|
+
description: "Confirms a new address before the change takes effect.",
|
|
97
|
+
default_asset: "emails/email-change-confirmation.png"
|
|
98
|
+
}
|
|
99
|
+
].freeze
|
|
100
|
+
|
|
101
|
+
# Banners render full-bleed at 600px in a 600px card. 1200x600 is the
|
|
102
|
+
# right cut: 2:1, retina-sharp at render width, and small enough to stay
|
|
103
|
+
# out of an inbox clipping limit.
|
|
104
|
+
ASPECT_RATIO = 2.0
|
|
105
|
+
MAX_WIDTH = 1200
|
|
106
|
+
|
|
107
|
+
module_function
|
|
108
|
+
|
|
109
|
+
# --- Registry ----------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
# Register (or update) an email workflow. Returns the key.
|
|
112
|
+
#
|
|
113
|
+
# Every keyword is OPTIONAL and omitting one on a re-register KEEPS the
|
|
114
|
+
# existing value — that is what lets a host relabel an inherited email, or
|
|
115
|
+
# attach a preview builder to it, without restating its artwork.
|
|
116
|
+
def register(key, label: nil, description: nil, default_asset: nil, type: nil, preview: nil)
|
|
117
|
+
key = key.to_s
|
|
118
|
+
existing = registry[key]
|
|
119
|
+
registry[key] = Entry.new(
|
|
120
|
+
key: key,
|
|
121
|
+
label: label || existing&.label || key.humanize,
|
|
122
|
+
description: description || existing&.description,
|
|
123
|
+
default_asset: default_asset.nil? ? existing&.default_asset : default_asset.presence,
|
|
124
|
+
type: normalize_type(type || existing&.type),
|
|
125
|
+
preview: preview || existing&.preview
|
|
126
|
+
)
|
|
127
|
+
key
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Unknown types fall back to :transactional rather than raising — a typo in
|
|
131
|
+
# an initializer must not take the host's boot down over a display label.
|
|
132
|
+
def normalize_type(type)
|
|
133
|
+
symbol = type.to_s.strip.downcase.to_sym
|
|
134
|
+
TYPES.include?(symbol) ? symbol : DEFAULT_TYPE
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Every registered email, in display order: the standard two first, then the
|
|
138
|
+
# host's own in declaration order.
|
|
139
|
+
def entries
|
|
140
|
+
registry.values
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def entry(key)
|
|
144
|
+
registry[key.to_s]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def keys
|
|
148
|
+
registry.keys
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def known?(key)
|
|
152
|
+
registry.key?(key.to_s)
|
|
153
|
+
end
|
|
154
|
+
def registered?(key) = known?(key)
|
|
155
|
+
|
|
156
|
+
def label(key)
|
|
157
|
+
entry(key)&.label || key.to_s.humanize
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Legacy shape — key => label. Kept because it is the API the pre-registry
|
|
161
|
+
# admin page and any host that read VARIANTS were written against.
|
|
162
|
+
def variants
|
|
163
|
+
registry.transform_values(&:label)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Drops host registrations back to the standard two. For tests and for
|
|
167
|
+
# to_prepare re-registration.
|
|
168
|
+
def reset!
|
|
169
|
+
@registry = nil
|
|
170
|
+
@preview_errors = nil
|
|
171
|
+
registry
|
|
172
|
+
nil
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Seeded through the SAME normalization register() uses, so a standard entry
|
|
176
|
+
# is indistinguishable from a host-registered one (its `type` is a real
|
|
177
|
+
# symbol, not nil) and every reader can trust the shape.
|
|
178
|
+
def registry
|
|
179
|
+
@registry ||= STANDARD.each_with_object({}) do |attrs, out|
|
|
180
|
+
out[attrs[:key]] = Entry.new(**attrs, type: normalize_type(attrs[:type]), preview: attrs[:preview])
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# --- Resolution --------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
# Where the live banner for this email comes from:
|
|
187
|
+
# :app — this app uploaded its own (ImageCache row in its bucket)
|
|
188
|
+
# :default — the inherited engine default (gem asset)
|
|
189
|
+
# :none — no image at all; the email sends bannerless
|
|
190
|
+
def source(key)
|
|
191
|
+
return :app if record(key)
|
|
192
|
+
return :default if default_asset_path(key)
|
|
193
|
+
|
|
194
|
+
:none
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def app_owned?(key) = source(key) == :app
|
|
198
|
+
|
|
199
|
+
# --- Preview -----------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
def previewable?(key)
|
|
202
|
+
entry(key)&.previewable? || false
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def type(key)
|
|
206
|
+
entry(key)&.type || DEFAULT_TYPE
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Build the sample Mail for this email, or nil.
|
|
210
|
+
#
|
|
211
|
+
# NEVER raises. A preview builder is host code running against whatever
|
|
212
|
+
# sample data happens to be in this environment — an empty table, a fixture
|
|
213
|
+
# that moved, a mailer whose signature changed. Any of those must show up as
|
|
214
|
+
# a message ON the preview page, not as a 500 that takes the whole email
|
|
215
|
+
# manager down with it. Returns nil; ask #preview_error for the reason.
|
|
216
|
+
def preview_mail(key)
|
|
217
|
+
callable = entry(key)&.preview
|
|
218
|
+
return nil unless callable.respond_to?(:call)
|
|
219
|
+
|
|
220
|
+
@preview_errors ||= {}
|
|
221
|
+
@preview_errors.delete(key.to_s)
|
|
222
|
+
force_message(callable.call)
|
|
223
|
+
rescue StandardError, ScriptError => e
|
|
224
|
+
(@preview_errors ||= {})[key.to_s] = "#{e.class}: #{e.message}"
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The reason the last preview_mail(key) returned nil, or nil if it did not
|
|
229
|
+
# fail. Set by preview_mail; read by the page so it can say WHY.
|
|
230
|
+
def preview_error(key)
|
|
231
|
+
(@preview_errors ||= {})[key.to_s]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# The rendered HTML body of the preview, for the iframe. nil when the email
|
|
235
|
+
# has no builder or the builder failed.
|
|
236
|
+
def preview_html(key)
|
|
237
|
+
mail = preview_mail(key)
|
|
238
|
+
return nil if mail.nil?
|
|
239
|
+
|
|
240
|
+
(mail.html_part&.body || mail.body).to_s
|
|
241
|
+
rescue StandardError => e
|
|
242
|
+
(@preview_errors ||= {})[key.to_s] = "#{e.class}: #{e.message}"
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def preview_subject(key)
|
|
247
|
+
preview_mail(key)&.subject
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Force a builder's return value to a REAL mail, here, inside preview_mail's
|
|
251
|
+
# rescue.
|
|
252
|
+
#
|
|
253
|
+
# The documented idiom — `preview: -> { UserMailer.magic_link(user, token) }`
|
|
254
|
+
# — does not return a Mail. It returns an ActionMailer::MessageDelivery, a
|
|
255
|
+
# LAZY proxy: the mailer action has not run yet, and the first call to
|
|
256
|
+
# `subject` / `html_part` is what finally runs it. So without this, a builder
|
|
257
|
+
# that fails takes `callable.call` cleanly, records NO error, and then blows
|
|
258
|
+
# up later at `preview_subject` — outside every rescue, straight into the
|
|
259
|
+
# host's error handler, taking the whole page down. That is the exact failure
|
|
260
|
+
# this class exists to prevent, so the forcing belongs at the same layer as
|
|
261
|
+
# the rescue, not at each call site.
|
|
262
|
+
#
|
|
263
|
+
# Duck-typed rather than `is_a?(ActionMailer::MessageDelivery)`: it also
|
|
264
|
+
# covers Parameterized::MessageDelivery and any host's own lazy wrapper.
|
|
265
|
+
# Mail::Message does NOT respond to `message`, so a builder that already
|
|
266
|
+
# returns a real Mail passes straight through.
|
|
267
|
+
def force_message(result)
|
|
268
|
+
result.respond_to?(:message) ? result.message : result
|
|
269
|
+
end
|
|
270
|
+
private_class_method :force_message
|
|
271
|
+
|
|
272
|
+
# THIS APP'S OWN image only — nil when nothing has been uploaded here.
|
|
273
|
+
#
|
|
274
|
+
# This is the PRE-REGISTRY contract, kept EXACTLY: `url` has always meant
|
|
275
|
+
# "the admin-managed override, or nil", and callers were written to fall back
|
|
276
|
+
# themselves. turf-monster's mailer is the live example:
|
|
277
|
+
#
|
|
278
|
+
# @banner_url = Studio::EmailImage.url(:magic_link) || email_banner_url("magic-link-banner.jpg")
|
|
279
|
+
#
|
|
280
|
+
# Making `url` resolve to the engine default would make that `||` dead code
|
|
281
|
+
# and silently replace turf-monster's own branded 1200x600 banner with the
|
|
282
|
+
# engine's PLACEHOLDER in real sign-in email. A method whose signature is
|
|
283
|
+
# unchanged but whose return value flips from nil to a value is not additive.
|
|
284
|
+
# So the new two-layer resolution lives in resolved_url, and every existing
|
|
285
|
+
# caller keeps the behavior it was written against until its app adopts.
|
|
286
|
+
def url(key)
|
|
287
|
+
record(key)&.url
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# What ACTUALLY SHIPS on this email — the two-layer resolution. Absolute, so
|
|
291
|
+
# it resolves from an inbox. App-owned override first, then the inherited
|
|
292
|
+
# engine default, then nil (the mailer renders bannerless).
|
|
293
|
+
#
|
|
294
|
+
# This is what a mailer should call once its app has adopted the registry.
|
|
295
|
+
# The engine's own UserMailer already does, which is what gives an app with
|
|
296
|
+
# an empty bucket branded email on day one.
|
|
297
|
+
def resolved_url(key)
|
|
298
|
+
url(key) || default_url(key)
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# What the ADMIN PAGE previews. Same two layers as resolved_url, but a
|
|
302
|
+
# default stays a root-relative asset path so it renders correctly on
|
|
303
|
+
# whatever host and port this app is being viewed on (an absolute mailer
|
|
304
|
+
# asset_host is set for the inbox, not for a browser on localhost:3042).
|
|
305
|
+
def preview_url(key)
|
|
306
|
+
url(key) || default_asset_path(key)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# The ImageCache row holding this app's override, or nil (nothing uploaded /
|
|
310
|
+
# table not installed yet). Nil-safe so the mailer renders before any upload.
|
|
311
|
+
def record(key)
|
|
312
|
+
return nil unless table_ready?
|
|
313
|
+
|
|
314
|
+
::ImageCache.find_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Root-relative path to the inherited default asset, or nil when the email
|
|
318
|
+
# has no default registered or the host's pipeline cannot resolve it.
|
|
319
|
+
def default_asset_path(key)
|
|
320
|
+
asset = entry(key)&.default_asset
|
|
321
|
+
return nil if asset.nil? || asset.empty?
|
|
322
|
+
|
|
323
|
+
path = ActionController::Base.helpers.asset_path(asset)
|
|
324
|
+
path.presence
|
|
325
|
+
rescue StandardError
|
|
326
|
+
nil
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Absolute URL to the inherited default asset — what a mailer needs. Uses
|
|
330
|
+
# action_mailer.asset_host (set per env), falling back to the mailer's
|
|
331
|
+
# default_url_options host. Returns the bare path if neither is configured,
|
|
332
|
+
# which still renders in the local inbox preview.
|
|
333
|
+
def default_url(key)
|
|
334
|
+
path = default_asset_path(key)
|
|
335
|
+
return nil if path.nil?
|
|
336
|
+
return path if path.start_with?("http")
|
|
337
|
+
|
|
338
|
+
host = mailer_asset_host
|
|
339
|
+
host ? "#{host}#{path}" : path
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# --- Upload ------------------------------------------------------------
|
|
343
|
+
|
|
344
|
+
# Whether THIS app can accept an upload. False when the host never set
|
|
345
|
+
# Studio.s3_bucket_prefix — /admin/emails then shows inherited defaults
|
|
346
|
+
# read-only rather than 500ing on the first upload.
|
|
347
|
+
def uploads_available?
|
|
348
|
+
Studio::S3.configured? && table_ready?
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# Upload bytes to this app's bucket + upsert its ImageCache row (replacing
|
|
352
|
+
# any prior object). Returns the ::ImageCache. Raises on failure after
|
|
353
|
+
# cleaning up the new object.
|
|
354
|
+
def store(key, io:, content_type: nil)
|
|
355
|
+
s3_key = "email_banners/#{key}-#{SecureRandom.hex(4)}#{ext_for(content_type)}"
|
|
356
|
+
Studio::S3.upload(key: s3_key, body: io.read, content_type: content_type,
|
|
357
|
+
cache_control: "public, max-age=300")
|
|
358
|
+
record = ::ImageCache.find_or_initialize_by(owner: nil, purpose: PURPOSE, variant: key.to_s)
|
|
359
|
+
previous = record.s3_key
|
|
360
|
+
record.update!(s3_key: s3_key)
|
|
361
|
+
delete_object(previous) if previous.present? && previous != s3_key
|
|
362
|
+
record
|
|
363
|
+
rescue StandardError
|
|
364
|
+
delete_object(s3_key)
|
|
365
|
+
raise
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Drop this app's override and fall back to the inherited default. Returns
|
|
369
|
+
# true when a row was removed.
|
|
370
|
+
def revert(key)
|
|
371
|
+
row = record(key)
|
|
372
|
+
return false if row.nil?
|
|
373
|
+
|
|
374
|
+
previous = row.s3_key
|
|
375
|
+
row.destroy!
|
|
376
|
+
delete_object(previous) if previous.present?
|
|
377
|
+
true
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# --- Internals ---------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
# Reference ImageCache directly so Zeitwerk autoloads it — defined?() does NOT
|
|
383
|
+
# trigger autoload, so it would read "undefined" for a not-yet-loaded const.
|
|
384
|
+
def table_ready?
|
|
385
|
+
::ImageCache.table_exists?
|
|
386
|
+
rescue NameError, ActiveRecord::ActiveRecordError
|
|
387
|
+
false
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# The origin an email's banner URL hangs off. action_mailer.asset_host when
|
|
391
|
+
# the host sets one (turf-monster does, per env); otherwise built from the
|
|
392
|
+
# mailer's default_url_options.
|
|
393
|
+
#
|
|
394
|
+
# That fallback has to reconstruct a real origin, not just the hostname.
|
|
395
|
+
# default_url_options is routinely {host: "localhost", port: 3001} — taking
|
|
396
|
+
# :host alone and prefixing "https://" yields https://localhost, which is the
|
|
397
|
+
# wrong scheme AND the wrong port, and the banner comes back
|
|
398
|
+
# ERR_CONNECTION_REFUSED. Caught by opening the preview page on a worktree
|
|
399
|
+
# stack; every dev/QA preview took that path.
|
|
400
|
+
def mailer_asset_host
|
|
401
|
+
configured = Rails.application.config.action_mailer.asset_host.presence
|
|
402
|
+
return configured if configured
|
|
403
|
+
|
|
404
|
+
options = ActionMailer::Base.default_url_options || {}
|
|
405
|
+
host = options[:host].presence
|
|
406
|
+
return nil if host.nil?
|
|
407
|
+
return host if host.start_with?("http")
|
|
408
|
+
|
|
409
|
+
"#{mailer_protocol(options, host)}://#{host}#{mailer_port_suffix(options)}"
|
|
410
|
+
rescue StandardError
|
|
411
|
+
nil
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
# Honor an explicit :protocol. Otherwise https — EXCEPT on loopback, which is
|
|
415
|
+
# a dev stack with no TLS. Defaulting the other way would downgrade every
|
|
416
|
+
# production app that sets only {host: "mcritchie.studio"}.
|
|
417
|
+
def mailer_protocol(options, host)
|
|
418
|
+
explicit = options[:protocol].presence
|
|
419
|
+
return explicit.to_s.sub(%r{://\z}, "") if explicit
|
|
420
|
+
|
|
421
|
+
LOOPBACK_HOSTS.include?(host.downcase) ? "http" : "https"
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Ports are part of the origin, and omitting one sends the reader to :443.
|
|
425
|
+
# The scheme defaults are left off so a normal URL stays normal.
|
|
426
|
+
def mailer_port_suffix(options)
|
|
427
|
+
port = options[:port]
|
|
428
|
+
return "" if port.blank? || [80, 443].include?(port.to_i)
|
|
429
|
+
|
|
430
|
+
":#{port}"
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 0.0.0.0 ::1].freeze
|
|
434
|
+
|
|
435
|
+
def ext_for(content_type)
|
|
436
|
+
case content_type.to_s
|
|
437
|
+
when %r{png} then ".png"
|
|
438
|
+
when %r{jpe?g} then ".jpg"
|
|
439
|
+
when %r{webp} then ".webp"
|
|
440
|
+
else ".png"
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def delete_object(key)
|
|
445
|
+
Studio::S3.delete(key: key)
|
|
446
|
+
rescue StandardError
|
|
447
|
+
nil
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
end
|