studio-engine 0.101.0 → 0.102.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: 246f4e1f6c97a3d413fb3742ee419565546ec4fbcb44c139360a99c69b708cfb
4
- data.tar.gz: cae5242cf1894749491d43a2170983eeca8d70088da294084d66b51222d6befc
3
+ metadata.gz: 3892a67d3cb43b26a47398f6f1560d2bb6f8d00265815dd86532e77acef4e869
4
+ data.tar.gz: 0ea0fb8690a31529d28cac58c22c695e6fe726d3ba5a30ecd9cd65d78b9f90d6
5
5
  SHA512:
6
- metadata.gz: da0a0aac743481907e836f5b1e331dea6572a0c9b5f09d12291ea4ccf2e852b81d9750ed01e9ccd765451fa4c1b62e1eef999064fd5586aed3318cbb953e5483
7
- data.tar.gz: d4d90ff3d300c6f8a846d4cb747683291171b9e93a90c07e0cfc981c565a12df97512435af135a303f7a0fb20e2ec4844d0ae6abe9b95b1fbadffaab2d2446c0
6
+ metadata.gz: 9e24a1c2ffc63f222044117d12716a455204a0a58e1548092083eb9955ffc29dda9f855a4bc14efea79d44437fd48b5abe6e8311deaadc94078e979640e46f3c
7
+ data.tar.gz: a383db6a52bc983e52ead696868cc3d8f36d5e878606879a969faec430e12e5dd3733245c437e3204a516db9c4a0460000067083710799c407f4b3411a528b00
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 SES SMTP when `MAIL_TRANSPORT=ses` and SES SMTP credentials are
127
- present, otherwise falls back to Resend when `RESEND_API_KEY` is present.
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 SES/Resend/unknown connector icon
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. The trash copy
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 "ses" if Studio.ses_transport_ready?
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
@@ -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
- if selected == "ses" && !ses_ready
25
- log(logger, :warn, "[mail] MAIL_TRANSPORT=ses but SES_SMTP_USERNAME/PASSWORD missing - keeping fallback transport")
26
- elsif present?(selected) && !%w[ses resend].include?(selected)
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
@@ -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
- client.copy_object(
75
- bucket: bucket,
76
- copy_source: copy_source(bucket, key),
77
- key: trash_key,
78
- metadata_directive: "REPLACE",
79
- metadata: trash_metadata(head, key: key, env: env, now: now),
80
- **carried_headers(head)
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
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.101.0"
2
+ VERSION = "0.102.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1136,29 +1136,20 @@ module Studio
1136
1136
  yield self
1137
1137
  end
1138
1138
 
1139
- def self.mailer_from_for_transport(env: ENV, ses_from:, resend_from: nil)
1140
- if ses_transport_ready?(env)
1141
- env_value(env, "MAILER_FROM") || ses_from
1142
- else
1143
- env_value(env, "RESEND_MAILER_FROM") || resend_from || resend_mailer_from
1144
- end
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
- def self.ses_transport_ready?(env = ENV)
1159
- env["MAIL_TRANSPORT"].to_s.downcase == "ses" &&
1160
- env_value(env, "SES_SMTP_USERNAME") &&
1161
- env_value(env, "SES_SMTP_PASSWORD")
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)
@@ -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 and studio_ses.rake: an app that also loads
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.101.0
4
+ version: 0.102.0
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
@@ -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