studio-engine 0.101.0 → 0.102.0.rc1
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 +40 -0
- data/README.md +9 -7
- data/app/helpers/studio_email_delivery_helper.rb +1 -6
- data/lib/active_storage/service/studio_trash_s3_service.rb +2 -1
- data/lib/studio/email_smoke.rb +0 -11
- data/lib/studio/engine.rb +0 -1
- data/lib/studio/mail_transport.rb +7 -24
- data/lib/studio/s3/trash.rb +38 -9
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +12 -21
- data/lib/tasks/studio_trash.rake +1 -1
- metadata +1 -3
- data/app/assets/images/ses-favicon.png +0 -0
- data/lib/tasks/studio_ses.rake +0 -130
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7eb8bd189cd5861c7aad2afb33f1d00ebe6bfa103e9e106dade9363d66cb91a4
|
|
4
|
+
data.tar.gz: 2381d4002f2c80cde9bb8a59243cc4836c99ce3abd90d8f97dfff50f7d89dad7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 87fb049ff0f6cb9041b0c070f9cde7f6c975e9eb04b275aa1cad4f3336ca89552d7ac3962a60e4ad9d12e0e2d970f9c6103403697e7d7d12762bec1c813295e4
|
|
7
|
+
data.tar.gz: 4b96de3893bf63d4d1ce75971078972e83d1be52189e5f58ffd74e05ebe4a86888168d563b5a1966a9db90934f3fe1c74ba4e684dbdb31879062fdd3fe9d75c6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,46 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.102.0 — 2026-10-10
|
|
8
|
+
|
|
9
|
+
### Removed
|
|
10
|
+
|
|
11
|
+
- **The SES mail transport.** AWS was retired on 2026-10-10 (no IAM users, no
|
|
12
|
+
SES identities), so the engine no longer configures SES SMTP. Removed:
|
|
13
|
+
`Studio::MailTransport.configure_ses!`, `Studio.ses_transport_ready?`, the
|
|
14
|
+
`ses:check` and `ses:verify_domain` rake tasks, the banner's SES connector
|
|
15
|
+
and `ses-favicon.png`. The engine reads no `SES_*` variable and no
|
|
16
|
+
`AWS_ACCESS_KEY_ID` fallback for mail. Resend and every other transport are
|
|
17
|
+
unchanged.
|
|
18
|
+
|
|
19
|
+
**Consumer note: `MAIL_TRANSPORT=ses` is no longer supported.** It now logs
|
|
20
|
+
`MAIL_TRANSPORT=ses is no longer supported` and falls through as if unset:
|
|
21
|
+
Resend when `RESEND_API_KEY` is set, else the existing delivery method. No
|
|
22
|
+
app in the fleet sets it (verified 2026-10-10: the two QA apps set
|
|
23
|
+
`MAIL_TRANSPORT=resend`, no Heroku app holds an `SES_*` or `AWS_*` variable).
|
|
24
|
+
Remove any `MAIL_TRANSPORT=ses` and `SES_*` lines from `.env.example` and docs
|
|
25
|
+
when you next touch them.
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- **`ses_from:` is optional and ignored.** `Studio.mailer_from_for_transport`
|
|
30
|
+
and `Studio.marketing_from_for_transport` still accept it, so every existing
|
|
31
|
+
initializer keeps booting, but they always answer the Resend sender
|
|
32
|
+
(`RESEND_MAILER_FROM` / `RESEND_MARKETING_FROM`, then `resend_from:`, then
|
|
33
|
+
`Studio.resend_mailer_from`). That was already the answer on every app,
|
|
34
|
+
because none had SES selected. Drop the keyword when convenient.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **A failed trash copy is recorded to ErrorLog.** When
|
|
39
|
+
`Studio::S3::Trash.trash!` (behind `Studio::S3.delete` and the
|
|
40
|
+
`StudioTrashS3` Active Storage service) cannot copy an object under
|
|
41
|
+
`trash/`, it now records a `Studio::S3::Trash::CopyFailed` through the host's
|
|
42
|
+
`ErrorLog.capture!`, naming the bucket, key and trash key, before re-raising
|
|
43
|
+
the original S3 error. The delete is still never sent after a failed copy.
|
|
44
|
+
Recording is best-effort: an `ErrorLog` that raises, or a host without one,
|
|
45
|
+
never masks the copy error.
|
|
46
|
+
|
|
7
47
|
## 0.101.0 — 2026-10-10
|
|
8
48
|
|
|
9
49
|
### Breaking
|
data/README.md
CHANGED
|
@@ -56,9 +56,7 @@ Studio.configure do |config|
|
|
|
56
56
|
config.welcome_message = ->(user) { "Welcome, #{user.display_name}!" }
|
|
57
57
|
config.auth_methods = %i[magic_link google]
|
|
58
58
|
config.registration_params = [:name, :email]
|
|
59
|
-
config.mailer_from = Studio.mailer_from_for_transport
|
|
60
|
-
ses_from: "My App <team@example.com>"
|
|
61
|
-
)
|
|
59
|
+
config.mailer_from = Studio.mailer_from_for_transport # RESEND_MAILER_FROM, else the shared sender
|
|
62
60
|
config.theme_primary = "#4BAF50" # Override default violet
|
|
63
61
|
config.theme_logos = ["logo.svg"]
|
|
64
62
|
|
|
@@ -123,8 +121,9 @@ Transactional mail transport is shared through `Studio::MailTransport`:
|
|
|
123
121
|
Studio::MailTransport.configure!
|
|
124
122
|
```
|
|
125
123
|
|
|
126
|
-
It selects
|
|
127
|
-
|
|
124
|
+
It selects Resend when `RESEND_API_KEY` is present. The SES transport was
|
|
125
|
+
removed with AWS on 2026-10-10; `MAIL_TRANSPORT=ses` now only logs a warning
|
|
126
|
+
([`docs/EMAIL_TRANSPORT.md`](docs/EMAIL_TRANSPORT.md)).
|
|
128
127
|
|
|
129
128
|
## Routes
|
|
130
129
|
|
|
@@ -162,7 +161,7 @@ The environment banner includes:
|
|
|
162
161
|
|
|
163
162
|
- a Dev Mode toggle button backed by `Alpine.store("devMode")`
|
|
164
163
|
- an Email status button that links to `/_studio/local_emails`
|
|
165
|
-
- a send/capture signal plus
|
|
164
|
+
- a send/capture signal plus Resend/unknown connector icon
|
|
166
165
|
|
|
167
166
|
Apps with admin Act As / impersonation state can render the matching banner
|
|
168
167
|
with their own users and return route:
|
|
@@ -1005,7 +1004,10 @@ avatars/abc.png -> trash/2026-10-01/1759302000123/avatars/abc.png
|
|
|
1005
1004
|
```
|
|
1006
1005
|
|
|
1007
1006
|
The copy goes first and the delete second. A copy that fails raises and the
|
|
1008
|
-
delete is never sent, so a failure leaves the original in place
|
|
1007
|
+
delete is never sent, so a failure leaves the original in place; the failed
|
|
1008
|
+
copy is also recorded through the host's `ErrorLog.capture!` as a
|
|
1009
|
+
`Studio::S3::Trash::CopyFailed` naming the bucket and key, before the original
|
|
1010
|
+
error is re-raised. The trash copy
|
|
1009
1011
|
keeps the object's content type, cache headers and metadata, and adds
|
|
1010
1012
|
`original-key`, `deleted-at`, `deleted-env`, and what an Active Storage blob row
|
|
1011
1013
|
needs to be rebuilt (`blob-content-type`, `blob-byte-size`, `blob-checksum`
|
|
@@ -30,7 +30,6 @@ module StudioEmailDeliveryHelper
|
|
|
30
30
|
def email_delivery_transport_label(delivery_method = studio_email_delivery_method,
|
|
31
31
|
capture_enabled = Studio.local_email_capture?)
|
|
32
32
|
return "capture" if capture_enabled
|
|
33
|
-
return "ses" if Studio.ses_transport_ready?
|
|
34
33
|
return "resend" if delivery_method == "resend"
|
|
35
34
|
|
|
36
35
|
delivery_method.to_s.strip.empty? ? "unknown" : delivery_method
|
|
@@ -39,16 +38,13 @@ module StudioEmailDeliveryHelper
|
|
|
39
38
|
private
|
|
40
39
|
|
|
41
40
|
def email_delivery_connector(delivery_method, transport)
|
|
42
|
-
return "
|
|
43
|
-
return "resend" if delivery_method == "resend"
|
|
44
|
-
return transport if %w[ses resend].include?(transport)
|
|
41
|
+
return "resend" if delivery_method == "resend" || transport == "resend"
|
|
45
42
|
|
|
46
43
|
nil
|
|
47
44
|
end
|
|
48
45
|
|
|
49
46
|
def email_delivery_connector_label(connector)
|
|
50
47
|
case connector
|
|
51
|
-
when "ses" then "SES"
|
|
52
48
|
when "resend" then "Resend"
|
|
53
49
|
else "Unknown"
|
|
54
50
|
end
|
|
@@ -56,7 +52,6 @@ module StudioEmailDeliveryHelper
|
|
|
56
52
|
|
|
57
53
|
def email_delivery_provider_icon(connector)
|
|
58
54
|
case connector
|
|
59
|
-
when "ses" then "ses-favicon.png"
|
|
60
55
|
when "resend" then "resend-favicon.png"
|
|
61
56
|
end
|
|
62
57
|
end
|
|
@@ -38,7 +38,8 @@ module ActiveStorage
|
|
|
38
38
|
rescue StandardError => error
|
|
39
39
|
# Logged, then RE-RAISED. Blob#purge has already destroyed the row, so a
|
|
40
40
|
# raise here leaks the object rather than losing it: the failure that
|
|
41
|
-
# costs storage, never the one that costs a user's file.
|
|
41
|
+
# costs storage, never the one that costs a user's file. A failed COPY
|
|
42
|
+
# has already been recorded to ErrorLog by Studio::S3::Trash.trash!.
|
|
42
43
|
log_trash_failure(key, error)
|
|
43
44
|
raise
|
|
44
45
|
end
|
data/lib/studio/email_smoke.rb
CHANGED
|
@@ -112,7 +112,6 @@ module Studio
|
|
|
112
112
|
|
|
113
113
|
def transport_label(action_mailer:, env: ENV)
|
|
114
114
|
return "capture" if studio_local_email_capture?
|
|
115
|
-
return "ses" if studio_ses_transport_ready?(env)
|
|
116
115
|
|
|
117
116
|
delivery_method = action_mailer.delivery_method.to_s
|
|
118
117
|
return "resend" if delivery_method == "resend"
|
|
@@ -158,16 +157,6 @@ module Studio
|
|
|
158
157
|
value if value && !value.to_s.strip.empty?
|
|
159
158
|
end
|
|
160
159
|
|
|
161
|
-
def studio_ses_transport_ready?(env)
|
|
162
|
-
if defined?(Studio) && Studio.respond_to?(:ses_transport_ready?)
|
|
163
|
-
Studio.ses_transport_ready?(env)
|
|
164
|
-
else
|
|
165
|
-
env["MAIL_TRANSPORT"].to_s.downcase == "ses" &&
|
|
166
|
-
env_value(env, "SES_SMTP_USERNAME") &&
|
|
167
|
-
env_value(env, "SES_SMTP_PASSWORD")
|
|
168
|
-
end
|
|
169
|
-
end
|
|
170
|
-
|
|
171
160
|
def studio_local_email_capture?
|
|
172
161
|
defined?(Studio) && Studio.respond_to?(:local_email_capture?) && Studio.local_email_capture?
|
|
173
162
|
end
|
data/lib/studio/engine.rb
CHANGED
|
@@ -213,7 +213,6 @@ module Studio
|
|
|
213
213
|
|
|
214
214
|
rake_tasks do
|
|
215
215
|
load File.expand_path("../tasks/studio_email.rake", __dir__)
|
|
216
|
-
load File.expand_path("../tasks/studio_ses.rake", __dir__)
|
|
217
216
|
end
|
|
218
217
|
|
|
219
218
|
# ---- A FOOTER-ONLY CONSUMER: a host with no ActiveRecord ------------------
|
|
@@ -19,21 +19,17 @@ module Studio
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
selected = env["MAIL_TRANSPORT"].to_s.downcase
|
|
22
|
-
ses_ready = selected == "ses" && present?(env["SES_SMTP_USERNAME"]) && present?(env["SES_SMTP_PASSWORD"])
|
|
23
22
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
# SES was retired with AWS on 2026-10-10 (no IAM users, no SES
|
|
24
|
+
# identities), so MAIL_TRANSPORT=ses no longer selects anything. It
|
|
25
|
+
# warns and falls through to Resend exactly like an unset value, so a
|
|
26
|
+
# stale config var never stops mail.
|
|
27
|
+
if selected == "ses"
|
|
28
|
+
log(logger, :warn, "[mail] MAIL_TRANSPORT=ses is no longer supported (SES retired) - using Resend when RESEND_API_KEY is set")
|
|
29
|
+
elsif present?(selected) && selected != "resend"
|
|
27
30
|
log(logger, :warn, "[mail] unknown MAIL_TRANSPORT=#{selected.inspect} - keeping fallback transport")
|
|
28
31
|
end
|
|
29
32
|
|
|
30
|
-
if ses_ready
|
|
31
|
-
configure_ses!(env: env, action_mailer: action_mailer)
|
|
32
|
-
region = env.fetch("SES_REGION", "us-east-2")
|
|
33
|
-
log(logger, :info, "[mail] transport=SES region=#{region} from=#{mailer_from}")
|
|
34
|
-
return Result.new(transport: :ses, delivery_method: action_mailer.delivery_method, message: "SES SMTP active")
|
|
35
|
-
end
|
|
36
|
-
|
|
37
33
|
if present?(env["RESEND_API_KEY"])
|
|
38
34
|
resend_loader.call
|
|
39
35
|
resend_configurer.call(env["RESEND_API_KEY"])
|
|
@@ -45,19 +41,6 @@ module Studio
|
|
|
45
41
|
Result.new(transport: :default, delivery_method: action_mailer.delivery_method, message: "no transactional transport configured")
|
|
46
42
|
end
|
|
47
43
|
|
|
48
|
-
def configure_ses!(env:, action_mailer:)
|
|
49
|
-
region = env.fetch("SES_REGION", "us-east-2")
|
|
50
|
-
action_mailer.delivery_method = :smtp
|
|
51
|
-
action_mailer.smtp_settings = {
|
|
52
|
-
address: env.fetch("SES_SMTP_HOST", "email-smtp.#{region}.amazonaws.com"),
|
|
53
|
-
port: env.fetch("SES_SMTP_PORT", 587).to_i,
|
|
54
|
-
user_name: env["SES_SMTP_USERNAME"],
|
|
55
|
-
password: env["SES_SMTP_PASSWORD"],
|
|
56
|
-
authentication: :login,
|
|
57
|
-
enable_starttls_auto: true
|
|
58
|
-
}
|
|
59
|
-
end
|
|
60
|
-
|
|
61
44
|
def load_resend!
|
|
62
45
|
require "resend"
|
|
63
46
|
end
|
data/lib/studio/s3/trash.rb
CHANGED
|
@@ -21,7 +21,9 @@ module Studio
|
|
|
21
21
|
# THE ORDER IS THE SAFETY. Copy first, delete second, and a copy that fails
|
|
22
22
|
# RAISES before the delete is ever sent. A failure anywhere leaves the
|
|
23
23
|
# original where it was: a leaked object costs storage, a lost one costs a
|
|
24
|
-
# user's photo.
|
|
24
|
+
# user's photo. A failed copy is also recorded to the host's ErrorLog
|
|
25
|
+
# (CopyFailed, naming the object) before the original error is re-raised,
|
|
26
|
+
# so a leak is visible on /error_logs rather than only in a log line.
|
|
25
27
|
#
|
|
26
28
|
# Everything here works on REAL object keys (any Studio.s3_key_prefix
|
|
27
29
|
# already applied) and an explicit client + bucket, so Studio::S3 and the
|
|
@@ -52,6 +54,11 @@ module Studio
|
|
|
52
54
|
class ProductionBucketRefused < Studio::S3::Error; end
|
|
53
55
|
class NotTrash < Studio::S3::Error; end
|
|
54
56
|
class RestoreConflict < Studio::S3::Error; end
|
|
57
|
+
# What a failed trash copy records to the host's ErrorLog: the S3 error
|
|
58
|
+
# alone names no object, so this carries bucket, key and trash key, with
|
|
59
|
+
# the failing copy's backtrace. It is RECORDED, never raised; trash!
|
|
60
|
+
# re-raises the original error so callers rescue what they always did.
|
|
61
|
+
class CopyFailed < Studio::S3::Error; end
|
|
55
62
|
|
|
56
63
|
TRASH_KEY = %r{\Atrash/(\d{4}-\d{2}-\d{2})/(\d+)/(.+)\z}m
|
|
57
64
|
|
|
@@ -71,14 +78,19 @@ module Studio
|
|
|
71
78
|
end
|
|
72
79
|
|
|
73
80
|
trash_key = key_for(key, now: now)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
begin
|
|
82
|
+
client.copy_object(
|
|
83
|
+
bucket: bucket,
|
|
84
|
+
copy_source: copy_source(bucket, key),
|
|
85
|
+
key: trash_key,
|
|
86
|
+
metadata_directive: "REPLACE",
|
|
87
|
+
metadata: trash_metadata(head, key: key, env: env, now: now),
|
|
88
|
+
**carried_headers(head)
|
|
89
|
+
)
|
|
90
|
+
rescue StandardError => error
|
|
91
|
+
record_copy_failure(error, bucket: bucket, key: key, trash_key: trash_key)
|
|
92
|
+
raise
|
|
93
|
+
end
|
|
82
94
|
# Reached only when the copy returned. A raise above never deletes.
|
|
83
95
|
client.delete_object(bucket: bucket, key: key)
|
|
84
96
|
trash_key
|
|
@@ -181,6 +193,23 @@ module Studio
|
|
|
181
193
|
|
|
182
194
|
private
|
|
183
195
|
|
|
196
|
+
# Record a failed copy through the host's ErrorLog (the engine's
|
|
197
|
+
# non-request error primitive) when it has one. Best-effort: ErrorLog
|
|
198
|
+
# writes to the database and can itself raise, and that must never
|
|
199
|
+
# replace the copy error trash! is about to re-raise.
|
|
200
|
+
def record_copy_failure(error, bucket:, key:, trash_key:)
|
|
201
|
+
return unless defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
202
|
+
|
|
203
|
+
record = CopyFailed.new(
|
|
204
|
+
"trash copy of #{bucket}/#{key} to #{trash_key} failed, object left in place: " \
|
|
205
|
+
"#{error.class}: #{error.message}"
|
|
206
|
+
)
|
|
207
|
+
record.set_backtrace(error.backtrace || caller)
|
|
208
|
+
::ErrorLog.capture!(record)
|
|
209
|
+
rescue StandardError, ScriptError
|
|
210
|
+
nil
|
|
211
|
+
end
|
|
212
|
+
|
|
184
213
|
def head(client, bucket, key)
|
|
185
214
|
client.head_object(bucket: bucket, key: key)
|
|
186
215
|
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -1136,29 +1136,20 @@ module Studio
|
|
|
1136
1136
|
yield self
|
|
1137
1137
|
end
|
|
1138
1138
|
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
end
|
|
1146
|
-
|
|
1147
|
-
def self.marketing_from_for_transport(env: ENV, ses_from:, resend_from: nil)
|
|
1148
|
-
if ses_transport_ready?(env)
|
|
1149
|
-
env_value(env, "MARKETING_MAILER_FROM") || ses_from
|
|
1150
|
-
else
|
|
1151
|
-
env_value(env, "RESEND_MARKETING_FROM") ||
|
|
1152
|
-
env_value(env, "RESEND_MAILER_FROM") ||
|
|
1153
|
-
resend_from ||
|
|
1154
|
-
resend_mailer_from
|
|
1155
|
-
end
|
|
1139
|
+
# The sender for transactional mail. Resend is the only provider (SES was
|
|
1140
|
+
# retired 2026-10-10): RESEND_MAILER_FROM, then resend_from, then
|
|
1141
|
+
# Studio.resend_mailer_from. `ses_from:` is still ACCEPTED so existing
|
|
1142
|
+
# consumer initializers keep booting, and IGNORED; drop it at your leisure.
|
|
1143
|
+
def self.mailer_from_for_transport(env: ENV, ses_from: nil, resend_from: nil)
|
|
1144
|
+
env_value(env, "RESEND_MAILER_FROM") || resend_from || resend_mailer_from
|
|
1156
1145
|
end
|
|
1157
1146
|
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
env_value(env, "
|
|
1147
|
+
# The sender for marketing mail, by the same rule; `ses_from:` is ignored.
|
|
1148
|
+
def self.marketing_from_for_transport(env: ENV, ses_from: nil, resend_from: nil)
|
|
1149
|
+
env_value(env, "RESEND_MARKETING_FROM") ||
|
|
1150
|
+
env_value(env, "RESEND_MAILER_FROM") ||
|
|
1151
|
+
resend_from ||
|
|
1152
|
+
resend_mailer_from
|
|
1162
1153
|
end
|
|
1163
1154
|
|
|
1164
1155
|
def self.env_value(env, key)
|
data/lib/tasks/studio_trash.rake
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
#
|
|
15
15
|
# Logic lives in Studio::S3::Trash (unit-tested); these tasks only resolve the
|
|
16
16
|
# bucket and print.
|
|
17
|
-
# Guarded like studio_email.rake
|
|
17
|
+
# Guarded like studio_email.rake: an app that also loads
|
|
18
18
|
# this file must not register a second copy of each action.
|
|
19
19
|
unless Rake::Task.task_defined?("studio:trash:restore")
|
|
20
20
|
namespace :studio do
|
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.102.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -256,7 +256,6 @@ files:
|
|
|
256
256
|
- app/assets/images/emails/magic-link.gif
|
|
257
257
|
- app/assets/images/emails/newsletter-subscribed-background.gif
|
|
258
258
|
- app/assets/images/resend-favicon.png
|
|
259
|
-
- app/assets/images/ses-favicon.png
|
|
260
259
|
- app/assets/images/state-flags/ak.svg
|
|
261
260
|
- app/assets/images/state-flags/al.svg
|
|
262
261
|
- app/assets/images/state-flags/ar.svg
|
|
@@ -778,7 +777,6 @@ files:
|
|
|
778
777
|
- lib/studio/version.rb
|
|
779
778
|
- lib/tasks/studio_email.rake
|
|
780
779
|
- lib/tasks/studio_knowledge.rake
|
|
781
|
-
- lib/tasks/studio_ses.rake
|
|
782
780
|
- lib/tasks/studio_trash.rake
|
|
783
781
|
- studio-engine.gemspec
|
|
784
782
|
- tailwind/studio.tailwind.config.js
|
|
Binary file
|
data/lib/tasks/studio_ses.rake
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
unless Rake::Task.task_defined?("ses:check")
|
|
4
|
-
namespace :ses do
|
|
5
|
-
def studio_ses_env_value(name)
|
|
6
|
-
value = ENV[name]
|
|
7
|
-
return nil if value.nil? || value.to_s.strip.empty?
|
|
8
|
-
|
|
9
|
-
value
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def studio_ses_credential(name)
|
|
13
|
-
studio_ses_env_value("SES_#{name}") || studio_ses_env_value(name)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def studio_ses_credential_source(name)
|
|
17
|
-
return "SES_#{name}" if studio_ses_env_value("SES_#{name}")
|
|
18
|
-
return name if studio_ses_env_value(name)
|
|
19
|
-
|
|
20
|
-
"missing"
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def studio_ses_signer(region)
|
|
24
|
-
require "aws-sigv4"
|
|
25
|
-
|
|
26
|
-
access_key_id = studio_ses_credential("AWS_ACCESS_KEY_ID")
|
|
27
|
-
secret_access_key = studio_ses_credential("AWS_SECRET_ACCESS_KEY")
|
|
28
|
-
missing = []
|
|
29
|
-
missing << "SES_AWS_ACCESS_KEY_ID or AWS_ACCESS_KEY_ID" unless access_key_id
|
|
30
|
-
missing << "SES_AWS_SECRET_ACCESS_KEY or AWS_SECRET_ACCESS_KEY" unless secret_access_key
|
|
31
|
-
abort "ses:* needs #{missing.join(' and ')}." if missing.any?
|
|
32
|
-
|
|
33
|
-
Aws::Sigv4::Signer.new(
|
|
34
|
-
service: "ses",
|
|
35
|
-
region: region,
|
|
36
|
-
access_key_id: access_key_id,
|
|
37
|
-
secret_access_key: secret_access_key
|
|
38
|
-
)
|
|
39
|
-
rescue LoadError
|
|
40
|
-
abort "ses:* needs aws-sigv4; it is usually available through aws-sdk-s3."
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def studio_ses_request(signer, region, method, path, body = nil)
|
|
44
|
-
require "net/http"
|
|
45
|
-
require "json"
|
|
46
|
-
|
|
47
|
-
url = "https://email.#{region}.amazonaws.com#{path}"
|
|
48
|
-
headers = body ? { "content-type" => "application/json" } : {}
|
|
49
|
-
sig = signer.sign_request(http_method: method, url: url, body: body, headers: headers)
|
|
50
|
-
uri = URI(url)
|
|
51
|
-
http = Net::HTTP.new(uri.host, 443)
|
|
52
|
-
http.use_ssl = true
|
|
53
|
-
req = Net::HTTP.const_get(method.capitalize).new(uri)
|
|
54
|
-
headers.each { |key, value| req[key] = value }
|
|
55
|
-
sig.headers.each { |key, value| req[key] = value }
|
|
56
|
-
req.body = body if body
|
|
57
|
-
res = http.request(req)
|
|
58
|
-
[res.code.to_i, (JSON.parse(res.body) rescue { "raw" => res.body.to_s[0, 300] })]
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def studio_ses_error(body)
|
|
62
|
-
body["message"] || body["Message"] || body["raw"]
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
desc "Check SES account status and verified identities"
|
|
66
|
-
task check: :environment do
|
|
67
|
-
region = ENV.fetch("SES_REGION", "us-east-2")
|
|
68
|
-
signer = studio_ses_signer(region)
|
|
69
|
-
get = ->(path) { studio_ses_request(signer, region, "GET", path) }
|
|
70
|
-
|
|
71
|
-
code, account = get.call("/v2/email/account")
|
|
72
|
-
puts "GetAccount (region #{region}) -> HTTP #{code}"
|
|
73
|
-
puts " CredentialSource=#{studio_ses_credential_source('AWS_ACCESS_KEY_ID')}"
|
|
74
|
-
if code == 200
|
|
75
|
-
puts " SendingEnabled=#{account['SendingEnabled']} ProductionAccessEnabled=#{account['ProductionAccessEnabled']} Enforcement=#{account['EnforcementStatus']}"
|
|
76
|
-
else
|
|
77
|
-
puts " ERROR: #{studio_ses_error(account)}"
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
code, identities = get.call("/v2/email/identities")
|
|
81
|
-
puts "ListEmailIdentities -> HTTP #{code}"
|
|
82
|
-
if code == 200
|
|
83
|
-
list = identities["EmailIdentities"] || []
|
|
84
|
-
names = list.map { |identity| "#{identity['IdentityName']}(#{identity['VerifiedForSendingStatus'] ? 'verified' : 'pending'})" }
|
|
85
|
-
puts " identities: #{names.empty? ? '(none yet)' : names.join(', ')}"
|
|
86
|
-
else
|
|
87
|
-
puts " ERROR: #{studio_ses_error(identities)}"
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
transport = ENV.fetch("MAIL_TRANSPORT", "(unset -> resend)")
|
|
91
|
-
puts "Live transport: MAIL_TRANSPORT=#{transport} delivery_method=#{ActionMailer::Base.delivery_method}"
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
desc "Create a SES domain identity and print DKIM CNAME records"
|
|
95
|
-
task :verify_domain, [:domain] => :environment do |_task, args|
|
|
96
|
-
abort "Usage: ses:verify_domain[domain]" if args[:domain].blank?
|
|
97
|
-
|
|
98
|
-
domain = args[:domain]
|
|
99
|
-
region = ENV.fetch("SES_REGION", "us-east-2")
|
|
100
|
-
signer = studio_ses_signer(region)
|
|
101
|
-
body = { EmailIdentity: domain }.to_json
|
|
102
|
-
code, response = studio_ses_request(signer, region, "POST", "/v2/email/identities", body)
|
|
103
|
-
already_exists = "#{response['message']} #{response['Message']}".match?(/already exist/i)
|
|
104
|
-
|
|
105
|
-
if code == 409 || ([400, 409].include?(code) && already_exists)
|
|
106
|
-
code, response = studio_ses_request(signer, region, "GET", "/v2/email/identities/#{domain}")
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
if code != 200
|
|
110
|
-
puts "#{domain}: ERROR (HTTP #{code}) #{studio_ses_error(response)}"
|
|
111
|
-
next
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
tokens = response.dig("DkimAttributes", "Tokens") || []
|
|
115
|
-
status = response.dig("DkimAttributes", "Status") || response["VerifiedForSendingStatus"]
|
|
116
|
-
puts "== #{domain} (region #{region}, DKIM status: #{status}) =="
|
|
117
|
-
if tokens.empty?
|
|
118
|
-
puts " (no DKIM tokens returned)"
|
|
119
|
-
else
|
|
120
|
-
puts " Add these 3 CNAME records to #{domain}'s DNS:"
|
|
121
|
-
tokens.each do |token|
|
|
122
|
-
puts " NAME: #{token}._domainkey.#{domain}"
|
|
123
|
-
puts " TYPE: CNAME"
|
|
124
|
-
puts " VALUE: #{token}.dkim.amazonses.com"
|
|
125
|
-
puts ""
|
|
126
|
-
end
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|