studio-engine 0.5.4 → 0.5.5

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: bf5f392cf1242e7e541db8ceb7d4ad1865ef2f71f3c787e582de4ac51fc6f836
4
- data.tar.gz: 5498dc5d35fbe69c589ba197f3d6fadacbe2d30e93da8b226f0a7d5309ad5ec5
3
+ metadata.gz: 5ceb657a1e35df5801b4bf45a82d916d236d5497bd876b9b245997474411c310
4
+ data.tar.gz: 6d68e9c3fb7a338b22adf45dd00435d21e16ddfce4321c5e28eabd9108a93ba1
5
5
  SHA512:
6
- metadata.gz: 3c38ce794988d826bc9f91ad47786f314ea94dfebdfb42164741ffe70379dfb912d3073ac74f61f96a2814157fdcb651293c6d5d3ea9ce9927567d982c153120
7
- data.tar.gz: 6046671a635cef22c25af3231d3e562d258ab0fa45ab568d2255d2c734c44d3e27950acd49ead0bda282b17075def27a6ba6c8e35df14807b6df4e2575890d3c
6
+ metadata.gz: d2ffbe056170f338dca804feeaf5594aa470d0f26488514ba798403108d6091e3072fa58b92a2fb8908c14d14d26cff7620fdbfcb8cd04a4f984316d90ec05b2
7
+ data.tar.gz: 550d7dd3d351624c058d0a4db421be26c18dc0b357af54c3e9c575164bd78b908798e7e66fe87cd64b3dde648372337938ca4f7b31c52f8438676f58ba5c8d90
data/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
6
6
 
7
7
  No entries yet.
8
8
 
9
+ ## v0.5.5 (2026-06-14)
10
+
11
+ ### Added
12
+ - **Local email inbox** at `/_studio/local_emails` for non-production localhost requests. It lists recent outbox rows and exposes local proof links for magic-link, email-verification, wallet-export, and email-change emails.
13
+ - **`Studio.local_email_capture?`** — shared capture switch for local/worktree stacks. `LOCAL_EMAIL_CAPTURE=1` or `AGENT_WORKTREE=1` records delivery rows without enqueueing external sends.
14
+
9
15
  ## v0.5.4 (2026-06-14)
10
16
 
11
17
  ### Changed
data/README.md CHANGED
@@ -11,7 +11,7 @@ Shared Rails engine for McRitchie apps. Provides authentication, error handling,
11
11
  gem "studio-engine", "~> 0.5"
12
12
  ```
13
13
 
14
- Then `bundle install`. The current release is **v0.5.4**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
14
+ Then `bundle install`. The current release is **v0.5.5**; see [`CHANGELOG.md`](./CHANGELOG.md) for the history.
15
15
 
16
16
  > Published to RubyGems as of v0.4.0 (2026-05-17). New installs should use the RubyGems form, which the consumer Rails apps (`mcritchie-studio`, `turf-monster`) already use.
17
17
 
@@ -64,6 +64,8 @@ end
64
64
 
65
65
  This draws the enabled auth routes (`/login`, `/signup`, `/logout`, magic-link request/confirm/consume routes, Solana routes), OAuth callbacks, optional SSO routes, `/error_logs`, and `/admin/theme`. Magic-link emails point at the inert GET confirmation route; the single-use token is consumed only by the CSRF-protected POST to `magic_link_consume_path`.
66
66
 
67
+ In non-production local requests, this also draws `/_studio/local_emails`, a local email inbox for agent/worktree proof flows. Set `LOCAL_EMAIL_CAPTURE=1` or run with `AGENT_WORKTREE=1` to record outbox rows without sending real email.
68
+
67
69
  ## Overriding Views
68
70
 
69
71
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -0,0 +1,108 @@
1
+ module Studio
2
+ class LocalEmailsController < ApplicationController
3
+ skip_before_action :require_authentication, raise: false
4
+ layout false
5
+
6
+ before_action :require_local_development!
7
+
8
+ def index
9
+ @deliveries = delivery_records.map { |record| serialize_delivery(record) }
10
+
11
+ respond_to do |format|
12
+ format.html
13
+ format.json do
14
+ render json: {
15
+ capture_enabled: Studio.local_email_capture?,
16
+ inbox_url: request.original_url.sub(/\.json\z/, ""),
17
+ deliveries: @deliveries
18
+ }
19
+ end
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def require_local_development!
26
+ head :not_found if Rails.env.production? || !request.local?
27
+ end
28
+
29
+ def delivery_records
30
+ klass = delivery_class
31
+ return [] unless klass
32
+
33
+ klass.recent.limit(50)
34
+ end
35
+
36
+ def delivery_class
37
+ if Object.const_defined?(:EmailDelivery, false)
38
+ return Object.const_get(:EmailDelivery, false)
39
+ end
40
+
41
+ if Studio.const_defined?(:EmailDelivery, false) && Studio::EmailDelivery.respond_to?(:available?) && Studio::EmailDelivery.available?
42
+ return Studio::EmailDelivery
43
+ end
44
+
45
+ nil
46
+ end
47
+
48
+ def serialize_delivery(record)
49
+ args = deserialize_args(record.args)
50
+ kwargs = deserialize_kwargs(record.kwargs)
51
+ {
52
+ id: record.id,
53
+ email_key: record.email_key,
54
+ mailer: record.mailer,
55
+ action: record.action,
56
+ to: record.to,
57
+ sent: record.sent?,
58
+ sent_at: record.sent_at,
59
+ error: record.error,
60
+ created_at: record.created_at,
61
+ action_url: local_action_url(record, args),
62
+ args_preview: args.map { |arg| preview_value(arg) },
63
+ kwargs_preview: kwargs.transform_values { |value| preview_value(value) }
64
+ }
65
+ end
66
+
67
+ def deserialize_args(value)
68
+ ActiveJob::Arguments.deserialize(value || [])
69
+ rescue StandardError
70
+ []
71
+ end
72
+
73
+ def deserialize_kwargs(value)
74
+ deserialized = ActiveJob::Arguments.deserialize([value || {}]).first
75
+ deserialized.respond_to?(:symbolize_keys) ? deserialized.symbolize_keys : {}
76
+ rescue StandardError
77
+ {}
78
+ end
79
+
80
+ def local_action_url(record, args)
81
+ token = args[1].to_s
82
+ return if token.empty?
83
+
84
+ path =
85
+ case record.email_key
86
+ when /#magic_link\z/
87
+ "/magic_link/#{ERB::Util.url_encode(token)}"
88
+ when /#email_verification\z/
89
+ "/email_verification/#{ERB::Util.url_encode(token)}"
90
+ when /#wallet_export\z/
91
+ "/account/wallet/export/#{ERB::Util.url_encode(token)}"
92
+ when /#email_change_confirmation\z/
93
+ "/account/email/confirm/#{ERB::Util.url_encode(token)}"
94
+ end
95
+
96
+ "#{request.base_url}#{path}" if path
97
+ end
98
+
99
+ def preview_value(value)
100
+ case value
101
+ when String, Numeric, TrueClass, FalseClass, NilClass
102
+ value
103
+ else
104
+ value.respond_to?(:to_global_id) ? value.to_global_id.to_s : value.to_s
105
+ end
106
+ end
107
+ end
108
+ end
@@ -23,12 +23,13 @@ module Studio
23
23
  args: ActiveJob::Arguments.serialize(args),
24
24
  kwargs: ActiveJob::Arguments.serialize([kwargs]).first
25
25
  )
26
- Studio::EmailDeliveryJob.perform_later(record.id)
26
+ Studio::EmailDeliveryJob.perform_later(record.id) unless Studio.local_email_capture?
27
27
  record
28
28
  end
29
29
 
30
30
  def deliver_now!
31
31
  return if sent?
32
+ return update!(error: "local email capture enabled; not sent") if Studio.local_email_capture?
32
33
 
33
34
  pos = ActiveJob::Arguments.deserialize(args)
34
35
  kw = ActiveJob::Arguments.deserialize([kwargs]).first.symbolize_keys
@@ -0,0 +1,100 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>Local Emails - <%= Studio.app_name %></title>
7
+ <style>
8
+ * { box-sizing: border-box; }
9
+ body {
10
+ margin: 0;
11
+ background: #10101f;
12
+ color: #f8fafc;
13
+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ }
15
+ main { width: min(1180px, calc(100% - 32px)); margin: 32px auto; }
16
+ header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
17
+ h1 { margin: 0 0 8px; font-size: 28px; line-height: 1.2; }
18
+ p { margin: 0; color: #b6bdd3; }
19
+ .pill { display: inline-flex; align-items: center; border: 1px solid #3b3b62; border-radius: 999px; padding: 6px 12px; color: #d8dcf0; background: #18182b; font-size: 13px; white-space: nowrap; }
20
+ .notice { margin-bottom: 18px; border: 1px solid #3b3b62; border-radius: 8px; background: #18182b; padding: 14px 16px; color: #cbd2e8; }
21
+ table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 8px; background: #18182b; }
22
+ th, td { padding: 12px 14px; border-bottom: 1px solid #2f2f4d; text-align: left; vertical-align: top; }
23
+ th { color: #9fa7c3; font-size: 12px; text-transform: uppercase; }
24
+ td { color: #eef2ff; font-size: 14px; }
25
+ tr:last-child td { border-bottom: 0; }
26
+ code { color: #c4b5fd; word-break: break-word; }
27
+ .muted { color: #9fa7c3; }
28
+ .error { color: #fda4af; }
29
+ .button { display: inline-flex; align-items: center; justify-content: center; border-radius: 8px; padding: 8px 12px; background: #4baf50; color: #fff; font-weight: 700; text-decoration: none; white-space: nowrap; }
30
+ .empty { border: 1px solid #3b3b62; border-radius: 8px; background: #18182b; padding: 24px; color: #cbd2e8; }
31
+ @media (max-width: 760px) {
32
+ header { display: block; }
33
+ .pill { margin-top: 14px; }
34
+ table, thead, tbody, tr, th, td { display: block; }
35
+ thead { display: none; }
36
+ tr { border-bottom: 1px solid #2f2f4d; padding: 10px 0; }
37
+ td { border-bottom: 0; padding: 8px 14px; }
38
+ td::before { content: attr(data-label); display: block; color: #9fa7c3; font-size: 12px; text-transform: uppercase; margin-bottom: 4px; }
39
+ }
40
+ </style>
41
+ </head>
42
+ <body>
43
+ <main>
44
+ <header>
45
+ <div>
46
+ <h1>Local Emails</h1>
47
+ <p>Recent outbox rows for this local <%= Studio.app_name %> stack.</p>
48
+ </div>
49
+ <span class="pill">Capture <%= Studio.local_email_capture? ? "enabled" : "disabled" %></span>
50
+ </header>
51
+
52
+ <% if Studio.local_email_capture? %>
53
+ <div class="notice">This stack records email intents and does not send them to external providers.</div>
54
+ <% else %>
55
+ <div class="notice">Capture is disabled. This page still shows recent delivery rows, but this stack may send real email when workers run.</div>
56
+ <% end %>
57
+
58
+ <% if @deliveries.empty? %>
59
+ <div class="empty">No email deliveries have been recorded yet.</div>
60
+ <% else %>
61
+ <table>
62
+ <thead>
63
+ <tr>
64
+ <th>When</th>
65
+ <th>Message</th>
66
+ <th>To</th>
67
+ <th>Status</th>
68
+ <th>Proof URL</th>
69
+ </tr>
70
+ </thead>
71
+ <tbody>
72
+ <% @deliveries.each do |delivery| %>
73
+ <tr>
74
+ <td data-label="When"><%= delivery[:created_at]&.strftime("%Y-%m-%d %H:%M:%S") %></td>
75
+ <td data-label="Message"><code><%= delivery[:email_key] %></code></td>
76
+ <td data-label="To"><%= delivery[:to] %></td>
77
+ <td data-label="Status">
78
+ <% if delivery[:sent] %>
79
+ Sent
80
+ <% elsif delivery[:error].present? %>
81
+ <span class="error"><%= delivery[:error] %></span>
82
+ <% else %>
83
+ <span class="muted">Captured</span>
84
+ <% end %>
85
+ </td>
86
+ <td data-label="Proof URL">
87
+ <% if delivery[:action_url].present? %>
88
+ <a class="button" href="<%= delivery[:action_url] %>">Open link</a>
89
+ <% else %>
90
+ <span class="muted">No local action URL</span>
91
+ <% end %>
92
+ </td>
93
+ </tr>
94
+ <% end %>
95
+ </tbody>
96
+ </table>
97
+ <% end %>
98
+ </main>
99
+ </body>
100
+ </html>
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.5.4"
2
+ VERSION = "0.5.5"
3
3
  end
data/lib/studio.rb CHANGED
@@ -41,6 +41,10 @@ module Studio
41
41
  # verified sending address in config/initializers/studio.rb.
42
42
  mattr_accessor :mailer_from, default: nil
43
43
 
44
+ # Local/worktree email capture. nil means "auto": enabled when AGENT_WORKTREE
45
+ # is truthy, otherwise disabled. Production always disables capture.
46
+ mattr_accessor :local_email_capture, default: nil
47
+
44
48
  # Theme role colors (7 roles)
45
49
  mattr_accessor :theme_primary, default: "#8E82FE"
46
50
  mattr_accessor :theme_dark, default: "#1A1535"
@@ -88,6 +92,13 @@ module Studio
88
92
  auth_methods.include?(method.to_sym)
89
93
  end
90
94
 
95
+ def self.local_email_capture?
96
+ return false if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
97
+ return !!local_email_capture unless local_email_capture.nil?
98
+
99
+ env_truthy?(ENV["LOCAL_EMAIL_CAPTURE"]) || env_truthy?(ENV["AGENT_WORKTREE"])
100
+ end
101
+
91
102
  # Verifies that the host app's User model satisfies the engine's expected
92
103
  # contract. Raises Studio::UserContractError with a clear pointer to
93
104
  # docs/USER_CONTRACT.md if anything required is missing. Called from
@@ -146,6 +157,10 @@ module Studio
146
157
  entry ? "/#{entry[:file]}" : nil
147
158
  end
148
159
 
160
+ def self.env_truthy?(value)
161
+ %w[1 true yes on].include?(value.to_s.strip.downcase)
162
+ end
163
+
149
164
  def self.routes(router)
150
165
  router.instance_exec do
151
166
  get "login", to: "sessions#new"
@@ -158,6 +173,10 @@ module Studio
158
173
  get "auth/:provider/callback", to: "omniauth_callbacks#create"
159
174
  get "auth/failure", to: "omniauth_callbacks#failure"
160
175
 
176
+ unless defined?(Rails) && Rails.env.production?
177
+ get "_studio/local_emails", to: "studio/local_emails#index", as: :studio_local_emails
178
+ end
179
+
161
180
  # Passwordless email (magic link). Helpers: magic_link_request_path (POST
162
181
  # to request a link), magic_link_path(token) / magic_link_url(token:)
163
182
  # for the emailed GET confirmation page, and magic_link_consume_path(token)
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.5.4
4
+ version: 0.5.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
@@ -150,6 +150,7 @@ files:
150
150
  - app/controllers/schema_controller.rb
151
151
  - app/controllers/sessions_controller.rb
152
152
  - app/controllers/solana_sessions_controller.rb
153
+ - app/controllers/studio/local_emails_controller.rb
153
154
  - app/controllers/theme_settings_controller.rb
154
155
  - app/helpers/studio_theme_helper.rb
155
156
  - app/jobs/error_log_cleanup_job.rb
@@ -191,6 +192,7 @@ files:
191
192
  - app/views/sessions/_sso_continue.html.erb
192
193
  - app/views/sessions/new.html.erb
193
194
  - app/views/studio/_cropper_assets.html.erb
195
+ - app/views/studio/local_emails/index.html.erb
194
196
  - app/views/studio/modals/_crop_photo.html.erb
195
197
  - app/views/studio/modals/_host.html.erb
196
198
  - app/views/studio/modals/_image_upload.html.erb