Skip to content

Commit 66d13a9

Browse files
Hélio Costaclaude
andcommitted
Fix keyword-call crash, path encoding, and missing-key error shape
- Emails.send declared idempotency_key: as a keyword, so Ruby 3 keyword separation rejected the documented bare-keyword call shape (Emails.send(from:, to:, ...) raised ArgumentError). Drop declared keywords: params and options are plain positional hashes, so both the bare-keyword quickstart and send(hash, idempotency_key: ...) bind. Batch.send matches. - Util.encode used form encoding, mapping space to "+" — wrong in a path segment. Re-map to %20 (safe: literal plus is already %2B). - Missing API key raised bare Error with no name; now ApplicationError with status_code nil and name "application_error", matching transport errors. - Specs: bare-keyword call shape, RFC 3986 segment encoding, and the strengthened missing-key assertion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 608d11c commit 66d13a9

5 files changed

Lines changed: 44 additions & 12 deletions

File tree

‎lib/millionsend/batch.rb‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ module Millionsend
44
# Send up to 100 emails in a single call.
55
module Batch
66
class << self
7-
# POST /emails/batch with a bare array body. Supports idempotency_key:.
8-
def send(list, idempotency_key: nil)
7+
# POST /emails/batch with a bare array body. A trailing options hash
8+
# carries idempotency_key (no keyword params, matching Emails.send).
9+
def send(list, options = {})
910
Millionsend::Request.new(
10-
method: :post, path: "/emails/batch", body: list, idempotency_key: idempotency_key
11+
method: :post, path: "/emails/batch", body: list, idempotency_key: options[:idempotency_key]
1112
).perform
1213
end
1314
alias_method :create, :send

‎lib/millionsend/emails.rb‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@ module Millionsend
44
# Transactional email: send one, look it up, cancel a scheduled one.
55
module Emails
66
class << self
7-
# POST /emails. Pass idempotency_key: to make a send safe to retry.
8-
def send(params, idempotency_key: nil)
7+
# POST /emails. Accepts a params hash or bare keywords
8+
# (Emails.send(from: ..., to: ...)); a trailing options hash carries
9+
# idempotency_key. No keyword parameters are declared on purpose — Ruby 3
10+
# keyword separation would otherwise reject the bare-keyword call shape.
11+
def send(params = {}, options = {})
912
Millionsend::Request.new(
10-
method: :post, path: "/emails", body: params, idempotency_key: idempotency_key
13+
method: :post, path: "/emails", body: params, idempotency_key: options[:idempotency_key]
1114
).perform
1215
end
1316
alias_method :create, :send

‎lib/millionsend/request.rb‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,11 @@ def initialize(method:, path:, body: nil, query: nil, idempotency_key: nil)
3737
def perform
3838
api_key = Millionsend.api_key
3939
if api_key.nil? || api_key.to_s.empty?
40-
raise Millionsend::Error.new(
41-
"Missing API key. Set Millionsend.api_key or the MILLIONSEND_API_KEY environment variable.", nil
40+
# Client-side failure: same class and stable name as a transport error,
41+
# so rescue Millionsend::ApplicationError / branching on e.name works.
42+
raise Millionsend::ApplicationError.new(
43+
"Missing API key. Set Millionsend.api_key or the MILLIONSEND_API_KEY environment variable.",
44+
nil, "application_error"
4245
)
4346
end
4447

‎lib/millionsend/util.rb‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@ module Util
88
module_function
99

1010
# Percent-encode a single URL path segment (contact ids and emails, which
11-
# can contain "@" and "+").
11+
# can contain "@" and "+"). encode_www_form_component is form encoding, so
12+
# it maps space to "+" — wrong inside a path segment, where servers decode
13+
# "+" literally. It already turned real plus signs into %2B, so every
14+
# remaining "+" is a space and can safely become %20.
1215
def encode(value)
13-
URI.encode_www_form_component(value.to_s)
16+
URI.encode_www_form_component(value.to_s).gsub("+", "%20")
1417
end
1518

1619
# The keyset pagination params every list endpoint accepts. nil values are

‎spec/millionsend_spec.rb‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,33 @@ def ok(body = '{"id":"e1"}')
130130
end
131131
end
132132

133-
it "raises when no API key is configured" do
133+
it "raises ApplicationError with a stable name when no API key is configured" do
134134
Millionsend.api_key = nil
135135
with_env("MILLIONSEND_API_KEY" => nil) do
136-
expect { Millionsend::Emails.get("e1") }.to raise_error(Millionsend::Error, /Missing API key/)
136+
expect { Millionsend::Emails.get("e1") }
137+
.to raise_error(Millionsend::ApplicationError, /Missing API key/) do |err|
138+
expect(err.status_code).to be_nil
139+
expect(err.name).to eq("application_error")
140+
end
137141
end
138142
end
139143
end
144+
145+
describe "call shapes" do
146+
it "accepts bare keywords, matching the README quickstart" do
147+
stub_request(:post, "https://api.test/emails").to_return(ok)
148+
Millionsend::Emails.send(from: "a@x.dev", to: "b@x.dev", subject: "s", html: "<p>h</p>")
149+
150+
expect(WebMock).to have_requested(:post, "https://api.test/emails").with(
151+
body: { "from" => "a@x.dev", "to" => "b@x.dev", "subject" => "s", "html" => "<p>h</p>" }
152+
)
153+
end
154+
end
155+
156+
describe Millionsend::Util do
157+
it "path-encodes a segment per RFC 3986 (space is %20, never +)" do
158+
expect(Millionsend::Util.encode("a b@x.dev")).to eq("a%20b%40x.dev")
159+
expect(Millionsend::Util.encode("a+b@x.dev")).to eq("a%2Bb%40x.dev")
160+
end
161+
end
140162
end

0 commit comments

Comments
 (0)