studio-engine 0.116.2 → 0.117.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 +32 -0
- data/README.md +1 -1
- data/app/mailers/application_mailer.rb +4 -1
- data/lib/studio/email_smoke.rb +9 -1
- data/lib/studio/engine.rb +15 -0
- data/lib/studio/mail_sender.rb +97 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +21 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 27c8703c859ad0039101776ceeeee457d6f2bd47df12a5180ebc20aa1127ad8c
|
|
4
|
+
data.tar.gz: 6b05c69deb801b08c6d1f6b31012220bf07b43cebb06b22e8becbd14fab159b0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 92bb22f3f1c1e28d7b2606541f2880ff098f3ff18986f29476fdfe66a9713c8e7565c72059e684a66b1c82251b86f0984b95bea4e8b2640775ddb0401a02e03a
|
|
7
|
+
data.tar.gz: 81332763ef29a2ccb68ca9f143b3c828022b5a3f42e6d47b30c63334ed59363a6decabe7e611c9e471bc1b9e539ade2be5f0b8ce71373d54ccce7d86f607ef05
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,38 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.117.0 — 2026-10-28
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- **The default sender names the app: `"<Studio.app_name> <team@mcritchie.studio>"`.**
|
|
12
|
+
With no `RESEND_MAILER_FROM` and no `resend_from:`,
|
|
13
|
+
`Studio.mailer_from_for_transport`, `Studio.marketing_from_for_transport`, the
|
|
14
|
+
engine's `ApplicationMailer` and `studio:email:smoke` now send as the app
|
|
15
|
+
(`Studio.default_mailer_from`) rather than as "McRitchie Studio"; an app that
|
|
16
|
+
never set `app_name` still sends as "McRitchie Studio". Every app sends from
|
|
17
|
+
team@mcritchie.studio; a client's own sending domain is a paid tier upgrade
|
|
18
|
+
set through `RESEND_MAILER_FROM`. `Studio.resend_mailer_from` defaults to
|
|
19
|
+
nil and reads that default; setting it still wins. The retired `ses_from:`
|
|
20
|
+
stays ignored, so an initializer passing only `ses_from:` now gets the app's
|
|
21
|
+
own name. Set `app_name` before calling the helper in the configure block.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Production refuses an example.com sender.** The engine's after_initialize
|
|
26
|
+
raises `Studio::MailSender::PlaceholderSenderError` in production when
|
|
27
|
+
`ApplicationMailer` or `UserMailer` resolves to an example.com, .net or .org
|
|
28
|
+
address: the Rails scaffold's `app/mailers/application_mailer.rb`
|
|
29
|
+
(`default from: "from@example.com"`) shadows the engine's, and Resend refused
|
|
30
|
+
every magic link in voicebox and moms-app. It runs in rake tasks too, so
|
|
31
|
+
Heroku's `assets:precompile` fails the build and the placeholder never
|
|
32
|
+
releases. The message names the fix. A sender proc that raises at boot is
|
|
33
|
+
logged and skipped. **Consumers still carrying the scaffold mailer must
|
|
34
|
+
delete it or re-point it at `Studio.mailer_from` before bumping.**
|
|
35
|
+
[`docs/NEW_APP_SETUP.md`](docs/NEW_APP_SETUP.md). Guarded by
|
|
36
|
+
`test/lib/studio/mail_sender_test.rb` and
|
|
37
|
+
`test/integration/mail_sender_boot_test.rb`.
|
|
38
|
+
|
|
7
39
|
## 0.116.2 — 2026-10-28
|
|
8
40
|
|
|
9
41
|
### Added
|
data/README.md
CHANGED
|
@@ -155,7 +155,7 @@ Studio.configure do |config|
|
|
|
155
155
|
config.welcome_message = ->(user) { "Welcome, #{user.display_name}!" }
|
|
156
156
|
config.auth_methods = %i[magic_link google]
|
|
157
157
|
config.registration_params = [:name, :email]
|
|
158
|
-
config.mailer_from = Studio.mailer_from_for_transport # RESEND_MAILER_FROM, else
|
|
158
|
+
config.mailer_from = Studio.mailer_from_for_transport # RESEND_MAILER_FROM, else "<app_name> <team@mcritchie.studio>"
|
|
159
159
|
config.theme_primary = "#4BAF50" # Override default violet
|
|
160
160
|
config.theme_logos = ["logo.svg"]
|
|
161
161
|
|
|
@@ -3,5 +3,8 @@ class ApplicationMailer < ActionMailer::Base
|
|
|
3
3
|
# the host's config/initializers/studio.rb, with an ENV fallback. No layout is
|
|
4
4
|
# forced — ActionMailer renders bare if the host ships no mailer layout, and a
|
|
5
5
|
# host that defines its own ApplicationMailer (e.g. turf-monster) wins outright.
|
|
6
|
-
|
|
6
|
+
#
|
|
7
|
+
# Unset, it is "<Studio.app_name> <team@mcritchie.studio>": every app sends
|
|
8
|
+
# from team@ under its own name (a client's own domain is a tier upgrade).
|
|
9
|
+
default from: -> { Studio.mailer_from.presence || ENV["MAILER_FROM"].presence || Studio.default_mailer_from }
|
|
7
10
|
end
|
data/lib/studio/email_smoke.rb
CHANGED
|
@@ -107,7 +107,7 @@ module Studio
|
|
|
107
107
|
def sender(env = ENV)
|
|
108
108
|
studio_value(:mailer_from) ||
|
|
109
109
|
env_value(env, "MAILER_FROM") ||
|
|
110
|
-
|
|
110
|
+
default_sender
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def transport_label(action_mailer:, env: ENV)
|
|
@@ -128,6 +128,14 @@ module Studio
|
|
|
128
128
|
|
|
129
129
|
private
|
|
130
130
|
|
|
131
|
+
def default_sender
|
|
132
|
+
if defined?(Studio) && Studio.respond_to?(:default_mailer_from)
|
|
133
|
+
Studio.default_mailer_from
|
|
134
|
+
else
|
|
135
|
+
"McRitchie Studio <team@mcritchie.studio>"
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
131
139
|
def body_for(app_name:, to:, from:, transport:, delivery_method:, perform_deliveries:, sent_at:)
|
|
132
140
|
<<~TEXT
|
|
133
141
|
Email smoke test for #{app_name}
|
data/lib/studio/engine.rb
CHANGED
|
@@ -347,6 +347,21 @@ module Studio
|
|
|
347
347
|
end
|
|
348
348
|
|
|
349
349
|
config.after_initialize do
|
|
350
|
+
# Refuse to boot in production when ApplicationMailer (or the magic-link
|
|
351
|
+
# UserMailer) sends from an example.com placeholder: the Rails scaffold's
|
|
352
|
+
# app/mailers/application_mailer.rb shadows the engine's and Resend
|
|
353
|
+
# refuses every send. Boot, not the email smoke: Heroku's build boots
|
|
354
|
+
# production for assets:precompile, so the build fails and the bad slug
|
|
355
|
+
# never releases, while the smoke is an operator step a deploy can skip.
|
|
356
|
+
# Rake tasks are checked too, on purpose. Studio::MailSender.
|
|
357
|
+
if defined?(::ActionMailer::Base)
|
|
358
|
+
Studio::MailSender.verify!(
|
|
359
|
+
mailer_classes: %w[ApplicationMailer UserMailer].filter_map(&:safe_constantize),
|
|
360
|
+
rails_env: Rails.env,
|
|
361
|
+
logger: Rails.logger
|
|
362
|
+
)
|
|
363
|
+
end
|
|
364
|
+
|
|
350
365
|
# Configure the IP -> location provider for every app that has the gem,
|
|
351
366
|
# so geo detection works the same way everywhere: HTTPS (without which
|
|
352
367
|
# ipinfo silently returns nothing), a 3s timeout, and a Rails.cache-backed
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# Who engine-sent mail comes from, and the production guard that refuses a
|
|
5
|
+
# placeholder sender.
|
|
6
|
+
#
|
|
7
|
+
# Every app sends from team@mcritchie.studio under its own name (Alex
|
|
8
|
+
# McRitchie's ruling, 2026-10-28); a client's own sending domain is a paid
|
|
9
|
+
# tier upgrade, set through RESEND_MAILER_FROM or `resend_from:`.
|
|
10
|
+
#
|
|
11
|
+
# The guard exists because `rails new` writes app/mailers/application_mailer.rb
|
|
12
|
+
# with `default from: "from@example.com"`. That class SHADOWS the engine's
|
|
13
|
+
# ApplicationMailer, so every mailer, the magic link included, inherits the
|
|
14
|
+
# placeholder, and Resend refuses every send. voicebox and moms-app both
|
|
15
|
+
# shipped it and nobody could sign in. Pure Ruby, so the unit suite exercises
|
|
16
|
+
# this file itself rather than a mirror of it.
|
|
17
|
+
module MailSender
|
|
18
|
+
TEAM_ADDRESS = "team@mcritchie.studio"
|
|
19
|
+
FALLBACK_NAME = "McRitchie Studio"
|
|
20
|
+
# The engine's own app_name default; an app that never named itself sends as
|
|
21
|
+
# the platform rather than as "Studio".
|
|
22
|
+
UNNAMED_APP = "Studio"
|
|
23
|
+
|
|
24
|
+
# RFC 2606 reserved second-level domains, and any host under them.
|
|
25
|
+
PLACEHOLDER_DOMAIN = /@(?:[a-z0-9-]+\.)*example\.(?:com|net|org)(?![a-z0-9.-])/i
|
|
26
|
+
# RFC 5322 "specials": a display name carrying one must be quoted.
|
|
27
|
+
DISPLAY_NAME_SPECIALS = /[()<>\[\]:;@\\,."]/
|
|
28
|
+
|
|
29
|
+
class PlaceholderSenderError < StandardError; end
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# "<app name> <team@mcritchie.studio>", the sender when nothing else is set.
|
|
34
|
+
def default_for(app_name)
|
|
35
|
+
name = app_name.to_s.strip
|
|
36
|
+
name = FALLBACK_NAME if name.empty? || name == UNNAMED_APP
|
|
37
|
+
"#{display_name(name)} <#{TEAM_ADDRESS}>"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def display_name(name)
|
|
41
|
+
return name unless name.match?(DISPLAY_NAME_SPECIALS)
|
|
42
|
+
|
|
43
|
+
%("#{name.gsub(/(["\\])/) { "\\#{Regexp.last_match(1)}" }}")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# True when any address in `from` (a String or an Array of them) sits on an
|
|
47
|
+
# example.com / .net / .org domain.
|
|
48
|
+
def placeholder?(from)
|
|
49
|
+
Array(from).any? { |address| address.to_s.match?(PLACEHOLDER_DOMAIN) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The From: a mailer class would stamp on a message that sets none itself:
|
|
53
|
+
# its `default from:`, with a proc default evaluated the way ActionMailer
|
|
54
|
+
# evaluates it (inside a mailer instance).
|
|
55
|
+
def resolve(mailer_class)
|
|
56
|
+
from = mailer_class.default_params[:from]
|
|
57
|
+
return from unless from.respond_to?(:call)
|
|
58
|
+
|
|
59
|
+
mailer = mailer_class.send(:new)
|
|
60
|
+
from.arity == 1 ? mailer.instance_exec(mailer, &from) : mailer.instance_exec(&from)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Raises PlaceholderSenderError in production when any of `mailer_classes`
|
|
64
|
+
# resolves to an example.com sender. Outside production it does nothing, so
|
|
65
|
+
# a test or a desk that never sends real mail is never stopped by it.
|
|
66
|
+
#
|
|
67
|
+
# It runs at boot (the engine's after_initialize), and Heroku's build runs
|
|
68
|
+
# `assets:precompile` in production, so a placeholder fails the BUILD: the
|
|
69
|
+
# bad slug never releases and the running release keeps serving. A sender
|
|
70
|
+
# that cannot be resolved at boot (its proc raises) is logged and skipped:
|
|
71
|
+
# the guard refuses a placeholder it can see, and never a boot it cannot read.
|
|
72
|
+
def verify!(mailer_classes:, rails_env:, logger: nil)
|
|
73
|
+
return :skipped unless rails_env.to_s == "production"
|
|
74
|
+
|
|
75
|
+
Array(mailer_classes).compact.uniq.each do |mailer_class|
|
|
76
|
+
from =
|
|
77
|
+
begin
|
|
78
|
+
resolve(mailer_class)
|
|
79
|
+
rescue StandardError => e
|
|
80
|
+
logger&.warn("[mail] could not resolve #{mailer_class.name}'s default sender at boot (#{e.class}: #{e.message}); placeholder guard skipped it")
|
|
81
|
+
next
|
|
82
|
+
end
|
|
83
|
+
raise PlaceholderSenderError, refusal(mailer_class, from) if placeholder?(from)
|
|
84
|
+
end
|
|
85
|
+
:verified
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def refusal(mailer_class, from)
|
|
89
|
+
<<~MSG.strip
|
|
90
|
+
#{mailer_class.name} sends from #{Array(from).join(', ').inspect}, an example.com placeholder, and Resend refuses every such send (no magic link would arrive).
|
|
91
|
+
The Rails scaffold's app/mailers/application_mailer.rb shadows the engine's ApplicationMailer. Delete it, or set:
|
|
92
|
+
default from: -> { Studio.mailer_from.presence || "#{default_for(defined?(Studio) && Studio.respond_to?(:app_name) ? Studio.app_name : nil)}" }
|
|
93
|
+
and pass resend_from: (not ses_from:) to Studio.mailer_from_for_transport in config/initializers/studio.rb. See studio-engine docs/NEW_APP_SETUP.md.
|
|
94
|
+
MSG
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -51,6 +51,7 @@ require "studio/agent_chat"
|
|
|
51
51
|
require "studio/username_generator"
|
|
52
52
|
require "studio/public_user"
|
|
53
53
|
require "studio/name_parts"
|
|
54
|
+
require "studio/mail_sender"
|
|
54
55
|
require "studio/s3"
|
|
55
56
|
require "studio/storage"
|
|
56
57
|
require "studio/image_cache"
|
|
@@ -1206,7 +1207,22 @@ module Studio
|
|
|
1206
1207
|
# Default From: for engine-sent mail (magic links). Apps set this to their
|
|
1207
1208
|
# verified sending address in config/initializers/studio.rb.
|
|
1208
1209
|
mattr_accessor :mailer_from, default: nil
|
|
1209
|
-
|
|
1210
|
+
# The Resend sender when neither RESEND_MAILER_FROM nor `resend_from:` is
|
|
1211
|
+
# given. Unset (nil), it reads "<Studio.app_name> <team@mcritchie.studio>"
|
|
1212
|
+
# (Studio::MailSender.default_for): every app sends from team@ under its own
|
|
1213
|
+
# name, and a client's own sending domain is a paid tier upgrade.
|
|
1214
|
+
mattr_writer :resend_mailer_from, default: nil
|
|
1215
|
+
|
|
1216
|
+
def self.resend_mailer_from
|
|
1217
|
+
value = @@resend_mailer_from
|
|
1218
|
+
value.nil? || value.to_s.strip.empty? ? default_mailer_from : value
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
# "<Studio.app_name> <team@mcritchie.studio>"; "McRitchie Studio" for an app
|
|
1222
|
+
# that never set app_name.
|
|
1223
|
+
def self.default_mailer_from
|
|
1224
|
+
Studio::MailSender.default_for(app_name)
|
|
1225
|
+
end
|
|
1210
1226
|
|
|
1211
1227
|
# Local/worktree email capture. nil means "auto": enabled when AGENT_WORKTREE
|
|
1212
1228
|
# is truthy, otherwise disabled. Production always disables capture.
|
|
@@ -1410,8 +1426,10 @@ module Studio
|
|
|
1410
1426
|
|
|
1411
1427
|
# The sender for transactional mail. Resend is the only provider (SES was
|
|
1412
1428
|
# retired 2026-10-10): RESEND_MAILER_FROM, then resend_from, then
|
|
1413
|
-
# Studio.resend_mailer_from
|
|
1414
|
-
#
|
|
1429
|
+
# Studio.resend_mailer_from (unset: "<app_name> <team@mcritchie.studio>").
|
|
1430
|
+
# `ses_from:` is still ACCEPTED so existing consumer initializers keep
|
|
1431
|
+
# booting, and IGNORED: pass resend_from: instead. Set app_name BEFORE this
|
|
1432
|
+
# call in the configure block, since the default reads it.
|
|
1415
1433
|
def self.mailer_from_for_transport(env: ENV, ses_from: nil, resend_from: nil)
|
|
1416
1434
|
env_value(env, "RESEND_MAILER_FROM") || resend_from || resend_mailer_from
|
|
1417
1435
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.117.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -774,6 +774,7 @@ files:
|
|
|
774
774
|
- lib/studio/link_token.rb
|
|
775
775
|
- lib/studio/local_path.rb
|
|
776
776
|
- lib/studio/log_rotation.rb
|
|
777
|
+
- lib/studio/mail_sender.rb
|
|
777
778
|
- lib/studio/mail_transport.rb
|
|
778
779
|
- lib/studio/name_parts.rb
|
|
779
780
|
- lib/studio/navbar_account_sidebar.rb
|