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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +179 -0
- data/app/controllers/resend_robot/mailbox_controller.rb +73 -0
- data/app/views/resend_robot/mailbox/index.html.erb +269 -0
- data/app/views/resend_robot/mailbox/show.html.erb +392 -0
- data/lib/generators/resend_robot/install_generator.rb +35 -0
- data/lib/generators/resend_robot/templates/initializer.rb +29 -0
- data/lib/generators/resend_robot/templates/skills/resend-robot-read/skill.md +64 -0
- data/lib/generators/resend_robot/templates/skills/resend-robot-send/skill.md +77 -0
- data/lib/resend_robot/configuration.rb +50 -0
- data/lib/resend_robot/engine.rb +47 -0
- data/lib/resend_robot/html_sanitizer.rb +33 -0
- data/lib/resend_robot/shim.rb +72 -0
- data/lib/resend_robot/storage.rb +231 -0
- data/lib/resend_robot/tasks/resend_robot.rake +78 -0
- data/lib/resend_robot/test_helper.rb +61 -0
- data/lib/resend_robot/version.rb +5 -0
- data/lib/resend_robot.rb +75 -0
- metadata +88 -0
|
@@ -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
|
data/lib/resend_robot.rb
ADDED
|
@@ -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: []
|