resend_robot 0.1.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.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "nokogiri"
4
+
5
+ module ResendRobot
6
+ # Sanitizes email HTML for inline rendering in the dev UI.
7
+ # Preserves tables/styles (needed for email layout) but strips XSS vectors:
8
+ # <script>, on* event handlers, javascript: URIs.
9
+ module HtmlSanitizer
10
+ class << self
11
+ def sanitize(html)
12
+ return "" if html.nil? || html.empty?
13
+
14
+ doc = Nokogiri::HTML.fragment(html)
15
+ doc.css("script").remove
16
+ doc.traverse do |node|
17
+ next unless node.element?
18
+
19
+ node.attributes.each_key { |attr| node.remove_attribute(attr) if attr.start_with?("on") }
20
+ end
21
+ doc.css("[href^='javascript:'], [src^='javascript:']").each do |n|
22
+ n.remove_attribute("href")
23
+ n.remove_attribute("src")
24
+ end
25
+ doc.css("a[href]").each do |a|
26
+ a["target"] = "_blank"
27
+ a["rel"] = "noopener noreferrer"
28
+ end
29
+ doc.to_html
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Monkey-patches Resend gem classes in development so production code
4
+ # runs unchanged against ResendRobot's local file store.
5
+ #
6
+ # SHIMMED METHODS
7
+ #
8
+ # Resend::Emails.send → ResendRobot.store_outbound
9
+ # Resend::Batch.send → store each email individually
10
+ # Resend::Emails::Receiving.get → ResendRobot.fetch_inbound
11
+ # Resend::Emails::Receiving::Attachments.get → stub
12
+ # Resend::Webhooks.verify → no-op, return true
13
+ #
14
+ module ResendRobot
15
+ module Shim
16
+ class << self
17
+ def install!
18
+ unless defined?(Resend)
19
+ ResendRobot.configuration.resolved_logger.warn(
20
+ "[ResendRobot] Resend gem not loaded — shim not installed. Add `gem 'resend'` to your Gemfile."
21
+ )
22
+ return
23
+ end
24
+
25
+ shim_emails_send
26
+ shim_batch_send
27
+ shim_receiving_get
28
+ shim_attachments_get
29
+ shim_webhooks_verify
30
+
31
+ ResendRobot.configuration.resolved_logger.info("[ResendRobot] Shim installed — Resend API calls intercepted in dev")
32
+ end
33
+
34
+ private
35
+
36
+ def shim_emails_send
37
+ Resend::Emails.define_singleton_method(:send) do |params, options: {}|
38
+ ResendRobot.store_outbound(params)
39
+ end
40
+ end
41
+
42
+ def shim_batch_send
43
+ Resend::Batch.define_singleton_method(:send) do |params = [], options: {}|
44
+ results = Array(params).map { |p| ResendRobot.store_outbound(p) }
45
+ { data: results }
46
+ end
47
+ end
48
+
49
+ def shim_receiving_get
50
+ Resend::Emails::Receiving.define_singleton_method(:get) do |email_id = ""|
51
+ data = ResendRobot.fetch_inbound(email_id)
52
+ ResendRobot.configuration.resolved_logger.info("[ResendRobot] Intercepted Receiving.get(#{email_id}) → #{data ? "found" : "nil"}")
53
+ data
54
+ end
55
+ end
56
+
57
+ def shim_attachments_get
58
+ Resend::Emails::Receiving::Attachments.define_singleton_method(:get) do |params = {}|
59
+ ResendRobot.configuration.resolved_logger.info("[ResendRobot] Intercepted Attachments.get(#{params[:id]}) → stub")
60
+ { id: params[:id], email_id: params[:email_id], filename: "stub.txt", content: "" }
61
+ end
62
+ end
63
+
64
+ def shim_webhooks_verify
65
+ Resend::Webhooks.define_singleton_method(:verify) do |params = {}|
66
+ ResendRobot.configuration.resolved_logger.info("[ResendRobot] Intercepted Webhooks.verify → true (dev bypass)")
67
+ true
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "net/http"
7
+ require "pathname"
8
+
9
+ # Resend Robot — dev-mode Resend API shim
10
+ #
11
+ # Intercepts outbound email (Resend::Emails.send) and stores as JSON.
12
+ # Simulates inbound email by POSTing to the real webhook endpoint.
13
+ # Production code runs unchanged — same pipeline in dev and prod.
14
+ #
15
+ # OUTBOUND FLOW
16
+ #
17
+ # Mailer.deliver!
18
+ # → Resend::Mailer#deliver!
19
+ # → Resend::Emails.send (shimmed)
20
+ # → ResendRobot.store_outbound
21
+ # → tmp/resend_robot/outbound/{timestamp}_{id}.json
22
+ #
23
+ # INBOUND FLOW
24
+ #
25
+ # ResendRobot.simulate_inbound(from:, to:, subject:, body:)
26
+ # → store_inbound (JSON to tmp/resend_robot/inbound/)
27
+ # → POST /webhooks/resend { type: "email.received" }
28
+ # → WebhooksController (UNCHANGED)
29
+ # → Receiving.get (shimmed → returns stored JSON)
30
+ # → handle_inbound_email (PRODUCTION CODE)
31
+ #
32
+ module ResendRobot
33
+ class Storage
34
+ OUTBOUND_DIR = "outbound"
35
+ INBOUND_DIR = "inbound"
36
+
37
+ attr_reader :config
38
+ attr_writer :base_dir # Override for tests
39
+
40
+ def initialize(config)
41
+ @config = config
42
+ @base_dir = nil
43
+ end
44
+
45
+ # Store an outbound email. Called by the shim replacing Resend::Emails.send.
46
+ # Returns a hash matching Resend API response format (symbol keys).
47
+ def store_outbound(params)
48
+ ensure_dir(outbound_dir)
49
+ id = "rl_#{SecureRandom.hex(8)}"
50
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S_%6N")
51
+
52
+ email_data = {
53
+ id: id,
54
+ sent_at: Time.now.iso8601,
55
+ from: params[:from],
56
+ to: Array(params[:to]),
57
+ cc: params[:cc],
58
+ bcc: params[:bcc],
59
+ reply_to: Array(params[:reply_to]),
60
+ subject: params[:subject],
61
+ html: params[:html],
62
+ text: params[:text],
63
+ tags: params[:tags],
64
+ headers: params[:headers]
65
+ }
66
+
67
+ path = outbound_dir.join("#{timestamp}_#{id}.json")
68
+ File.write(path, JSON.pretty_generate(email_data))
69
+
70
+ logger.info("[ResendRobot] Stored outbound #{id} → #{Array(params[:to]).join(", ")} | #{params[:subject]}")
71
+
72
+ if should_open_in_browser?
73
+ open_in_browser(id)
74
+ end
75
+
76
+ { id: id }
77
+ end
78
+
79
+ # Store an inbound email for later retrieval by the shimmed Receiving.get.
80
+ def store_inbound(email_id, data)
81
+ ensure_dir(inbound_dir)
82
+ path = inbound_dir.join("#{email_id}.json")
83
+ File.write(path, JSON.pretty_generate(data))
84
+ logger.info("[ResendRobot] Stored inbound #{email_id}")
85
+ end
86
+
87
+ # Fetch a stored inbound email. Called by the shim replacing Receiving.get.
88
+ def fetch_inbound(email_id)
89
+ return nil unless email_id.to_s.match?(/\Arl_inbound_[0-9a-f]+\z/)
90
+ path = inbound_dir.join("#{email_id}.json")
91
+ return nil unless path.exist?
92
+
93
+ JSON.parse(File.read(path), symbolize_names: true)
94
+ end
95
+
96
+ # List outbound emails, newest first. Only parses the first `limit` files.
97
+ def list_outbound(limit: 20)
98
+ ensure_dir(outbound_dir)
99
+ files = Dir.glob(outbound_dir.join("*.json")).sort.reverse.first(limit)
100
+ files.map { |f| JSON.parse(File.read(f), symbolize_names: true) }
101
+ end
102
+
103
+ # Find a single outbound email by its rl_xxx ID.
104
+ def find_outbound(id)
105
+ return nil unless id.to_s.match?(/\Arl_[0-9a-f]+\z/)
106
+ ensure_dir(outbound_dir)
107
+ pattern = outbound_dir.join("*_#{id}.json")
108
+ file = Dir.glob(pattern).first
109
+ return nil unless file
110
+
111
+ JSON.parse(File.read(file), symbolize_names: true)
112
+ end
113
+
114
+ # Most recent outbound email.
115
+ def last_outbound
116
+ list_outbound(limit: 1).first
117
+ end
118
+
119
+ # Simulate receiving an inbound email through the real webhook pipeline.
120
+ #
121
+ # 1. Stores email body in inbound dir
122
+ # 2. POSTs to localhost webhook endpoint
123
+ # 3. Controller fetches via shimmed Receiving.get
124
+ # 4. Production handle_inbound_email runs unchanged
125
+ def simulate_inbound(from:, to: nil, subject: "Simulated inbound", body: "Test reply body")
126
+ email_id = "rl_inbound_#{SecureRandom.hex(8)}"
127
+
128
+ # Auto-generate reply-domain address if configured and `to` doesn't match.
129
+ if config.reply_domain
130
+ to = ensure_reply_domain(to, config.reply_domain)
131
+ end
132
+
133
+ # Store full email content (shimmed Receiving.get will return this)
134
+ inbound_data = {
135
+ id: email_id,
136
+ from: from,
137
+ to: Array(to),
138
+ subject: subject,
139
+ text: body,
140
+ html: nil,
141
+ created_at: Time.now.iso8601
142
+ }
143
+ store_inbound(email_id, inbound_data)
144
+
145
+ # POST to the real webhook endpoint
146
+ webhook_payload = {
147
+ type: "email.received",
148
+ data: {
149
+ email_id: email_id,
150
+ from: from,
151
+ to: Array(to),
152
+ subject: subject,
153
+ text: body
154
+ }
155
+ }
156
+
157
+ port = config.dev_port
158
+ uri = URI("http://localhost:#{port}#{config.webhook_path}")
159
+
160
+ begin
161
+ response = Net::HTTP.post(uri, webhook_payload.to_json, "Content-Type" => "application/json")
162
+ logger.info("[ResendRobot] POSTed inbound webhook → #{response.code}")
163
+ rescue Errno::ECONNREFUSED
164
+ logger.error("[ResendRobot] Dev server not running on port #{port}. Start with `bin/dev` first.")
165
+ raise "Dev server not running on port #{port}. Start with `bin/dev` first."
166
+ end
167
+
168
+ email_id
169
+ end
170
+
171
+ # Remove all stored emails.
172
+ def clear!
173
+ FileUtils.rm_rf(outbound_dir)
174
+ FileUtils.rm_rf(inbound_dir)
175
+ logger.info("[ResendRobot] Cleared all stored emails")
176
+ end
177
+
178
+ private
179
+
180
+ def outbound_dir
181
+ base_path.join(OUTBOUND_DIR)
182
+ end
183
+
184
+ def inbound_dir
185
+ base_path.join(INBOUND_DIR)
186
+ end
187
+
188
+ def base_path
189
+ Pathname.new(@base_dir || config.resolved_storage_path)
190
+ end
191
+
192
+ def ensure_dir(dir)
193
+ FileUtils.mkdir_p(dir)
194
+ end
195
+
196
+ def logger
197
+ config.resolved_logger
198
+ end
199
+
200
+ def should_open_in_browser?
201
+ return false if ENV["OPEN_EMAILS"] == "0"
202
+ return false if defined?(Rails) && Rails.env.test?
203
+ config.open_in_browser
204
+ end
205
+
206
+ # Ensure the `to` address includes the reply domain.
207
+ # If not, generate a cold-inbound address on the reply domain.
208
+ def ensure_reply_domain(to, reply_domain)
209
+ if to.nil? || (to.respond_to?(:blank?) && to.blank?) || (to.respond_to?(:empty?) && to.empty?)
210
+ return "cold-#{SecureRandom.hex(4)}@#{reply_domain}"
211
+ end
212
+
213
+ addresses = Array(to)
214
+ if addresses.any? { |addr| addr.to_s.include?("@#{reply_domain}") }
215
+ to
216
+ else
217
+ # Prepend a reply-domain address so the webhook controller accepts it
218
+ ["cold-#{SecureRandom.hex(4)}@#{reply_domain}"] + addresses
219
+ end
220
+ end
221
+
222
+ def open_in_browser(id)
223
+ port = config.dev_port
224
+ mount = config.mount_path.chomp("/")
225
+ url = "http://localhost:#{port}#{mount}/#{id}"
226
+ system("open", url)
227
+ rescue => e
228
+ logger.warn("[ResendRobot] Could not open browser: #{e.message}")
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Resend Robot rake tasks — CLI for dev email interaction.
4
+ # Business logic lives in ResendRobot::Storage (tested, DRY).
5
+ # These tasks are thin orchestration only.
6
+
7
+ namespace :resend_robot do
8
+ desc "List recent outbound emails"
9
+ task outbox: :environment do
10
+ emails = ResendRobot.list_outbound(limit: 20)
11
+ if emails.empty?
12
+ puts "No emails. Send one via ActionMailer to see it here."
13
+ else
14
+ puts format("%-20s %-10s %-30s %s", "ID", "Time", "To", "Subject")
15
+ puts "-" * 90
16
+ emails.each do |email|
17
+ time = Time.parse(email[:sent_at]).strftime("%H:%M:%S") rescue email[:sent_at]
18
+ to = Array(email[:to]).first.to_s[0..27]
19
+ puts format("%-20s %-10s %-30s %s", email[:id], time, to, email[:subject])
20
+ end
21
+ end
22
+ end
23
+
24
+ desc "Show a specific outbound email by ID"
25
+ task :show, [:id] => :environment do |_t, args|
26
+ email = ResendRobot.find_outbound(args[:id])
27
+ if email.nil?
28
+ puts "Email not found: #{args[:id]}"
29
+ else
30
+ puts JSON.pretty_generate(email)
31
+ end
32
+ end
33
+
34
+ desc "Simulate receiving an inbound email"
35
+ task :receive, [:from, :to, :subject, :body] => :environment do |_t, args|
36
+ email_id = ResendRobot.simulate_inbound(
37
+ from: args[:from],
38
+ to: args[:to].presence,
39
+ subject: args[:subject] || "Simulated inbound",
40
+ body: args[:body] || "Test reply body"
41
+ )
42
+ puts "Inbound email stored and webhook POSTed: #{email_id}"
43
+ end
44
+
45
+ desc "Reply to the Nth most recent outbound email (0 = most recent)"
46
+ task :reply, [:index, :body] => :environment do |_t, args|
47
+ index = (args[:index] || "0").to_i
48
+ body = args[:body] || "Simulated reply from Resend Robot"
49
+
50
+ emails = ResendRobot.list_outbound(limit: index + 1)
51
+ email = emails[index]
52
+ if email.nil?
53
+ puts "No outbound email at index #{index}. Run `bin/rails resend_robot:outbox` to see available emails."
54
+ next
55
+ end
56
+
57
+ reply_to = Array(email[:reply_to]).first
58
+ to_address = Array(email[:to]).first
59
+
60
+ puts "Replying to: #{email[:subject]}"
61
+ puts " From: #{to_address} (original recipient)"
62
+ puts " To: #{reply_to} (reply-to address)"
63
+
64
+ email_id = ResendRobot.simulate_inbound(
65
+ from: to_address,
66
+ to: reply_to,
67
+ subject: "Re: #{email[:subject]}",
68
+ body: body
69
+ )
70
+ puts "Inbound email stored and webhook POSTed: #{email_id}"
71
+ end
72
+
73
+ desc "Clear all Resend Robot emails"
74
+ task clear: :environment do
75
+ ResendRobot.clear!
76
+ puts "Resend Robot mailbox cleared."
77
+ end
78
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Minitest assertions for verifying emails sent via Resend Robot.
4
+ #
5
+ # Usage:
6
+ # class UserSignupTest < ActiveSupport::TestCase
7
+ # include ResendRobot::TestHelper
8
+ #
9
+ # test "sends welcome email on signup" do
10
+ # clear_emails
11
+ # User.create!(email: "alice@example.com")
12
+ # assert_email_sent_to "alice@example.com", subject: /Welcome/
13
+ # assert_equal 1, emails_sent.count
14
+ # end
15
+ # end
16
+ #
17
+ module ResendRobot
18
+ module TestHelper
19
+ # All outbound emails stored since last clear.
20
+ def emails_sent
21
+ ResendRobot.list_outbound(limit: 1000)
22
+ end
23
+
24
+ # Most recent outbound email (symbol-keyed hash).
25
+ def last_email
26
+ ResendRobot.last_outbound
27
+ end
28
+
29
+ # Clear all stored emails. Call in setup to isolate tests.
30
+ def clear_emails
31
+ ResendRobot.clear!
32
+ end
33
+
34
+ # Assert that at least one email was sent to the given address.
35
+ # Optionally match subject (String or Regexp).
36
+ def assert_email_sent_to(address, subject: nil, message: nil)
37
+ matching = emails_sent.select do |email|
38
+ to_match = Array(email[:to]).any? { |to| to.include?(address) }
39
+ subject_match = case subject
40
+ when nil then true
41
+ when Regexp then email[:subject]&.match?(subject)
42
+ when String then email[:subject] == subject
43
+ else true
44
+ end
45
+ to_match && subject_match
46
+ end
47
+
48
+ msg = message || "Expected an email to #{address}"
49
+ msg += " with subject matching #{subject.inspect}" if subject
50
+ msg += ", but found #{emails_sent.count} email(s): #{emails_sent.map { |e| "#{Array(e[:to]).join(',')} | #{e[:subject]}" }.join('; ')}"
51
+
52
+ assert matching.any?, msg
53
+ end
54
+
55
+ # Assert that no emails were sent.
56
+ def assert_no_emails_sent(message: nil)
57
+ msg = message || "Expected no emails sent, but found #{emails_sent.count}"
58
+ assert emails_sent.empty?, msg
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ResendRobot
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "resend_robot/version"
4
+ require "resend_robot/configuration"
5
+
6
+ # Resend Robot — dev-mode Resend API shim
7
+ #
8
+ # Like letter_opener, but for the Resend gem. Intercepts Resend API calls
9
+ # in development, stores emails as JSON on disk, and provides a web UI
10
+ # to browse, preview, and simulate replies.
11
+ #
12
+ # Zero config needed — just add to your Gemfile:
13
+ #
14
+ # gem "resend_robot", group: :development
15
+ #
16
+ module ResendRobot
17
+ autoload :Storage, "resend_robot/storage"
18
+ autoload :Shim, "resend_robot/shim"
19
+ autoload :TestHelper, "resend_robot/test_helper"
20
+ autoload :HtmlSanitizer, "resend_robot/html_sanitizer"
21
+
22
+ class << self
23
+ def configuration
24
+ @configuration ||= Configuration.new
25
+ end
26
+
27
+ def configure
28
+ yield(configuration)
29
+ @storage = nil # reset so new config takes effect
30
+ end
31
+
32
+ def storage
33
+ @storage ||= Storage.new(configuration)
34
+ end
35
+
36
+ # Allow overriding storage (for tests)
37
+ attr_writer :storage
38
+
39
+ # ── Convenience delegations to Storage ──
40
+
41
+ def store_outbound(params)
42
+ storage.store_outbound(params)
43
+ end
44
+
45
+ def list_outbound(limit: 20)
46
+ storage.list_outbound(limit: limit)
47
+ end
48
+
49
+ def find_outbound(id)
50
+ storage.find_outbound(id)
51
+ end
52
+
53
+ def last_outbound
54
+ storage.last_outbound
55
+ end
56
+
57
+ def store_inbound(email_id, data)
58
+ storage.store_inbound(email_id, data)
59
+ end
60
+
61
+ def fetch_inbound(email_id)
62
+ storage.fetch_inbound(email_id)
63
+ end
64
+
65
+ def simulate_inbound(from:, to: nil, subject: "Simulated inbound", body: "Test reply body")
66
+ storage.simulate_inbound(from: from, to: to, subject: subject, body: body)
67
+ end
68
+
69
+ def clear!
70
+ storage.clear!
71
+ end
72
+ end
73
+ end
74
+
75
+ require "resend_robot/engine" if defined?(Rails::Engine)
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: resend_robot
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Garry Tan
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: resend
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '1.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '1.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: railties
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.0'
40
+ description: Intercepts Resend gem API calls in development, stores emails as JSON,
41
+ and provides a web UI to browse, preview, and simulate replies. Production code
42
+ runs unchanged — same pipeline in dev and prod.
43
+ email:
44
+ - garry@garryslist.org
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - LICENSE.txt
50
+ - README.md
51
+ - app/controllers/resend_robot/mailbox_controller.rb
52
+ - app/views/resend_robot/mailbox/index.html.erb
53
+ - app/views/resend_robot/mailbox/show.html.erb
54
+ - lib/generators/resend_robot/install_generator.rb
55
+ - lib/generators/resend_robot/templates/initializer.rb
56
+ - lib/generators/resend_robot/templates/skills/resend-robot-read/skill.md
57
+ - lib/generators/resend_robot/templates/skills/resend-robot-send/skill.md
58
+ - lib/resend_robot.rb
59
+ - lib/resend_robot/configuration.rb
60
+ - lib/resend_robot/engine.rb
61
+ - lib/resend_robot/html_sanitizer.rb
62
+ - lib/resend_robot/shim.rb
63
+ - lib/resend_robot/storage.rb
64
+ - lib/resend_robot/tasks/resend_robot.rake
65
+ - lib/resend_robot/test_helper.rb
66
+ - lib/resend_robot/version.rb
67
+ homepage: https://github.com/garrytan/resend_robot
68
+ licenses:
69
+ - MIT
70
+ metadata: {}
71
+ rdoc_options: []
72
+ require_paths:
73
+ - lib
74
+ required_ruby_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '3.1'
79
+ required_rubygems_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ requirements: []
85
+ rubygems_version: 4.0.4
86
+ specification_version: 4
87
+ summary: Dev-mode Resend API shim — like letter_opener for the Resend gem
88
+ test_files: []