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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 506b8003a5848c60123a2707d1d6a13a847e9bcb5a64f153357f5313d7916c21
4
- data.tar.gz: f5c669e411552093d0a8809d125b51edeaf14579c852d8fd87929f18ff6cf387
3
+ metadata.gz: 27c8703c859ad0039101776ceeeee457d6f2bd47df12a5180ebc20aa1127ad8c
4
+ data.tar.gz: 6b05c69deb801b08c6d1f6b31012220bf07b43cebb06b22e8becbd14fab159b0
5
5
  SHA512:
6
- metadata.gz: 128d8cf927f3a12da9133867bb17d4fabb59e033f7dc72f860af62d1d8eb23e4c33c622b6892138b1a2cc10af666ad4941d9782ae96158247fb4c8980644cd26
7
- data.tar.gz: c1b006da8088c23fd26bd7a32e7daef52b631b2dd0ea8e3f41646daad20e77a9b4445dc327cd3189ae1cd15265694fd8c8d62147229d98785ec8290e4a72942f
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 the shared sender
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
- default from: -> { Studio.mailer_from || ENV["MAILER_FROM"] || "McRitchie Studio <team@mcritchie.studio>" }
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
@@ -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
- "McRitchie Studio <team@mcritchie.studio>"
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.116.2"
2
+ VERSION = "0.117.0"
3
3
  end
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
- mattr_accessor :resend_mailer_from, default: "McRitchie Studio <team@mcritchie.studio>"
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. `ses_from:` is still ACCEPTED so existing
1414
- # consumer initializers keep booting, and IGNORED; drop it at your leisure.
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.116.2
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