cloudflare-email 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/CHANGELOG.md +87 -0
- data/LICENSE.txt +21 -0
- data/README.md +521 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
- data/lib/cloudflare/email/client.rb +216 -0
- data/lib/cloudflare/email/credentials.rb +59 -0
- data/lib/cloudflare/email/delivery_method.rb +57 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
- data/lib/cloudflare/email/dev_tunnel.rb +97 -0
- data/lib/cloudflare/email/doctor.rb +244 -0
- data/lib/cloudflare/email/engine.rb +25 -0
- data/lib/cloudflare/email/error.rb +20 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
- data/lib/cloudflare/email/provision_route_task.rb +34 -0
- data/lib/cloudflare/email/response.rb +61 -0
- data/lib/cloudflare/email/routing_provisioner.rb +220 -0
- data/lib/cloudflare/email/secure_message_id.rb +89 -0
- data/lib/cloudflare/email/send_test.rb +75 -0
- data/lib/cloudflare/email/signing.rb +37 -0
- data/lib/cloudflare/email/task_base.rb +61 -0
- data/lib/cloudflare/email/verification.rb +44 -0
- data/lib/cloudflare/email/version.rb +5 -0
- data/lib/cloudflare/email/worker_deployer.rb +183 -0
- data/lib/cloudflare-email.rb +9 -0
- data/lib/generators/cloudflare/email/install_generator.rb +227 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
- data/lib/tasks/cloudflare_email.rake +45 -0
- data/templates/worker/README.md +37 -0
- data/templates/worker/package.json +15 -0
- data/templates/worker/src/index.js +73 -0
- data/templates/worker/test/index.test.ts +139 -0
- data/templates/worker/vitest.config.ts +8 -0
- data/templates/worker/wrangler.toml +10 -0
- metadata +155 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "cloudflare/email/signing"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Signed outbound Message-IDs for reply authentication.
|
|
7
|
+
#
|
|
8
|
+
# Sign the outbound Message-ID with HMAC-SHA256. The recipient's reply
|
|
9
|
+
# naturally carries the signed id in `In-Reply-To:`, which your mailbox
|
|
10
|
+
# verifies and decodes to recover the original thread state.
|
|
11
|
+
#
|
|
12
|
+
# See README's "Signed replies" section for a full usage example.
|
|
13
|
+
module SecureMessageId
|
|
14
|
+
InvalidToken = Class.new(Cloudflare::Email::Error)
|
|
15
|
+
|
|
16
|
+
DEFAULT_PREFIX = "msg".freeze
|
|
17
|
+
DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 # 30 days
|
|
18
|
+
EPOCH_OFFSET = Time.utc(2026, 1, 1).to_i.freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# Build a signed Message-ID carrying `payload`. Returns the bare
|
|
22
|
+
# Message-ID without angle brackets — SMTP/Mail adds them.
|
|
23
|
+
def encode(payload:, domain:, secret:, prefix: DEFAULT_PREFIX, now: Time.now.to_i)
|
|
24
|
+
raise ArgumentError, "secret must not be empty" if secret.to_s.empty?
|
|
25
|
+
raise ArgumentError, "domain must not be empty" if domain.to_s.empty?
|
|
26
|
+
|
|
27
|
+
iat_offset = now.to_i - EPOCH_OFFSET
|
|
28
|
+
raise ArgumentError, "timestamp out of 32-bit range" if iat_offset.negative? || iat_offset >= (1 << 32)
|
|
29
|
+
|
|
30
|
+
packed = [iat_offset].pack("N") + JSON.generate(payload)
|
|
31
|
+
b64 = Signing.base64url_encode(packed)
|
|
32
|
+
mac = Signing.hmac_hex(secret, b64)
|
|
33
|
+
|
|
34
|
+
"#{prefix}.#{b64}.#{mac}@#{domain}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Decode a Message-ID produced by encode. Accepts `<bracketed>` form
|
|
38
|
+
# too. Returns parsed payload or raises InvalidToken.
|
|
39
|
+
def decode(message_id, secret:, max_age: DEFAULT_MAX_AGE, now: Time.now.to_i)
|
|
40
|
+
raise InvalidToken, "message-id is empty" if message_id.to_s.empty?
|
|
41
|
+
|
|
42
|
+
id = strip_brackets(message_id.to_s).strip
|
|
43
|
+
local, domain = id.split("@", 2)
|
|
44
|
+
raise InvalidToken, "missing @ in message-id" unless local && domain
|
|
45
|
+
|
|
46
|
+
_prefix, b64, mac = local.split(".", 3)
|
|
47
|
+
raise InvalidToken, "malformed message-id" unless b64 && mac && !b64.empty? && !mac.empty?
|
|
48
|
+
|
|
49
|
+
expected = Signing.hmac_hex(secret, b64)
|
|
50
|
+
raise InvalidToken, "signature mismatch" unless Signing.secure_compare(expected, mac)
|
|
51
|
+
|
|
52
|
+
packed = begin
|
|
53
|
+
Signing.base64url_decode(b64)
|
|
54
|
+
rescue StandardError
|
|
55
|
+
raise InvalidToken, "base64 decode failed"
|
|
56
|
+
end
|
|
57
|
+
raise InvalidToken, "truncated packed bytes" if packed.bytesize < 4
|
|
58
|
+
|
|
59
|
+
iat_offset = packed.byteslice(0, 4).unpack1("N")
|
|
60
|
+
iat = iat_offset + EPOCH_OFFSET
|
|
61
|
+
payload_json = packed.byteslice(4..)
|
|
62
|
+
|
|
63
|
+
raise InvalidToken, "token expired" if now - iat > max_age
|
|
64
|
+
raise InvalidToken, "token timestamp in the future" if iat - now > 5 * 60
|
|
65
|
+
|
|
66
|
+
JSON.parse(payload_json.to_s)
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
raise InvalidToken, "payload not valid JSON"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Cheap heuristic — does this look like one of our signed Message-IDs?
|
|
72
|
+
def match?(message_id, prefix: DEFAULT_PREFIX)
|
|
73
|
+
id = strip_brackets(message_id.to_s).strip
|
|
74
|
+
local, domain = id.split("@", 2)
|
|
75
|
+
return false unless local && domain
|
|
76
|
+
p, b64, mac = local.split(".", 3)
|
|
77
|
+
return false unless p && b64 && mac
|
|
78
|
+
p == prefix && !b64.empty? && mac.match?(/\A[0-9a-f]{64}\z/)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def strip_brackets(s)
|
|
84
|
+
s.start_with?("<") && s.end_with?(">") ? s[1..-2] : s
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require "cloudflare/email/task_base"
|
|
2
|
+
require "cloudflare/email/client"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# `bin/rails cloudflare:email:send_test TO=... [FROM=...]` — one-shot
|
|
7
|
+
# test send via the current Cloudflare Email config.
|
|
8
|
+
class SendTest < TaskBase
|
|
9
|
+
def self.call(to:, from: nil, io: $stdout)
|
|
10
|
+
new(io: io, to: to, from: from).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
require_value!(account_id, "cloudflare.account_id")
|
|
17
|
+
require_value!(api_token, "cloudflare.api_token")
|
|
18
|
+
require_value!(opts[:to], "TO=recipient@example.com")
|
|
19
|
+
|
|
20
|
+
sender = opts[:from] || infer_from
|
|
21
|
+
raise "Missing FROM= and couldn't infer from verified sending domains" if sender.to_s.empty?
|
|
22
|
+
|
|
23
|
+
say "Sending test email:"
|
|
24
|
+
say " from: #{sender}"
|
|
25
|
+
say " to: #{opts[:to]}"
|
|
26
|
+
say ""
|
|
27
|
+
|
|
28
|
+
client = Cloudflare::Email::Client.new(
|
|
29
|
+
account_id: account_id, api_token: api_token, retries: 0,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
response = client.send(
|
|
33
|
+
from: sender,
|
|
34
|
+
to: opts[:to],
|
|
35
|
+
subject: "[cloudflare-email test] #{Time.now.iso8601}",
|
|
36
|
+
text: "This is a test send from the cloudflare-email gem doctor.",
|
|
37
|
+
html: "<p>This is a test send from the <code>cloudflare-email</code> gem doctor.</p>" \
|
|
38
|
+
"<p>Sent at <strong>#{Time.now.iso8601}</strong>.</p>",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
say " success: #{response.success?}"
|
|
42
|
+
say " delivered: #{response.delivered.inspect}"
|
|
43
|
+
say " queued: #{response.queued.inspect}" if response.queued.any?
|
|
44
|
+
say " bounces: #{response.permanent_bounces.inspect}" if response.permanent_bounces.any?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def infer_from
|
|
50
|
+
require "net/http"
|
|
51
|
+
require "json"
|
|
52
|
+
|
|
53
|
+
uri = URI.parse("https://api.cloudflare.com/client/v4/accounts/#{account_id}/email/sending/domains")
|
|
54
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
55
|
+
http.use_ssl = true
|
|
56
|
+
http.open_timeout = 10
|
|
57
|
+
http.read_timeout = 10
|
|
58
|
+
|
|
59
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
60
|
+
req["Authorization"] = "Bearer #{api_token}"
|
|
61
|
+
response = http.request(req)
|
|
62
|
+
return nil unless response.code.to_i.between?(200, 299)
|
|
63
|
+
|
|
64
|
+
domains = JSON.parse(response.body).dig("result") || []
|
|
65
|
+
verified = domains.find { |d| d["verified"] == true || d["status"] == "verified" }
|
|
66
|
+
return nil unless verified
|
|
67
|
+
|
|
68
|
+
domain = verified["name"] || verified["domain"]
|
|
69
|
+
domain.to_s.empty? ? nil : "test@#{domain}"
|
|
70
|
+
rescue StandardError
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "openssl"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Shared cryptographic helpers: HMAC-SHA256, constant-time compare,
|
|
7
|
+
# base64url encoding. Used by Verification (ingress HMAC) and
|
|
8
|
+
# SecureMessageId (signed Message-IDs). Keeps every crypto primitive in
|
|
9
|
+
# one place so the hash algorithm, encoding choice, and compare function
|
|
10
|
+
# can't drift between call sites.
|
|
11
|
+
module Signing
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def hmac_hex(secret, data)
|
|
15
|
+
OpenSSL::HMAC.hexdigest("SHA256", secret, data)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def secure_compare(a, b)
|
|
19
|
+
if defined?(ActiveSupport::SecurityUtils)
|
|
20
|
+
ActiveSupport::SecurityUtils.secure_compare(a, b)
|
|
21
|
+
else
|
|
22
|
+
return false if a.bytesize != b.bytesize
|
|
23
|
+
OpenSSL.fixed_length_secure_compare(a, b)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def base64url_encode(bytes)
|
|
28
|
+
Base64.urlsafe_encode64(bytes, padding: false)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def base64url_decode(str)
|
|
32
|
+
padding = (4 - str.length % 4) % 4
|
|
33
|
+
Base64.urlsafe_decode64(str + ("=" * padding))
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
require "cloudflare/email/credentials"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# Shared scaffolding for `bin/rails cloudflare:email:*` tasks.
|
|
6
|
+
#
|
|
7
|
+
# Each task subclass implements `#run` (which raises on missing input or
|
|
8
|
+
# returns nil/0 on success). The base class wraps that with consistent
|
|
9
|
+
# credential exposure, error formatting, and exit codes so every task
|
|
10
|
+
# feels the same to the user.
|
|
11
|
+
#
|
|
12
|
+
# Subclasses use `credential(:name)` or the shorthand readers
|
|
13
|
+
# (`account_id`, `api_token`, `management_token`, `ingress_secret`)
|
|
14
|
+
# to pull config. Raising any exception from `#run` is converted to
|
|
15
|
+
# a non-zero exit with a uniformly-formatted error message.
|
|
16
|
+
class TaskBase
|
|
17
|
+
def self.call(**kwargs)
|
|
18
|
+
new(**kwargs).call
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(io: $stdout, **opts)
|
|
22
|
+
@io = io
|
|
23
|
+
@opts = opts
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call
|
|
27
|
+
run
|
|
28
|
+
0
|
|
29
|
+
rescue Cloudflare::Email::Error => e
|
|
30
|
+
@io.puts " ERROR: #{e.message}"
|
|
31
|
+
@io.puts " Status: #{e.status}" if e.status
|
|
32
|
+
1
|
|
33
|
+
rescue => e
|
|
34
|
+
@io.puts " ERROR: #{e.message}"
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
protected
|
|
39
|
+
|
|
40
|
+
attr_reader :io, :opts
|
|
41
|
+
|
|
42
|
+
def say(msg = "")
|
|
43
|
+
@io.puts(msg)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def credential(key)
|
|
47
|
+
Cloudflare::Email::Credentials.fetch(key)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def account_id = Cloudflare::Email::Credentials.account_id
|
|
51
|
+
def api_token = Cloudflare::Email::Credentials.api_token
|
|
52
|
+
def management_token = Cloudflare::Email::Credentials.management_token
|
|
53
|
+
def ingress_secret = Cloudflare::Email::Credentials.ingress_secret
|
|
54
|
+
|
|
55
|
+
def require_value!(value, label)
|
|
56
|
+
raise ArgumentError, "Missing #{label}" if value.to_s.empty?
|
|
57
|
+
value
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "cloudflare/email/signing"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# HMAC verification for inbound webhook signatures from the bundled
|
|
6
|
+
# Cloudflare Email Worker. Pure-Ruby and Rails-free so it can be
|
|
7
|
+
# unit-tested in isolation.
|
|
8
|
+
#
|
|
9
|
+
# Worker signs: HMAC-SHA256(secret, "{timestamp}.{raw_body}")
|
|
10
|
+
# Worker sends:
|
|
11
|
+
# X-CF-Email-Timestamp: <unix seconds>
|
|
12
|
+
# X-CF-Email-Signature: <hex digest>
|
|
13
|
+
module Verification
|
|
14
|
+
DEFAULT_WINDOW = 5 * 60 # seconds
|
|
15
|
+
|
|
16
|
+
# Returns :ok, :bad_signature, or :stale.
|
|
17
|
+
# Returns :bad_signature for any malformed input.
|
|
18
|
+
def self.verify(secret:, body:, timestamp:, signature:, window: DEFAULT_WINDOW, now: Time.now.to_i)
|
|
19
|
+
return :bad_signature if blank?(secret) || blank?(body) || blank?(timestamp) || blank?(signature)
|
|
20
|
+
|
|
21
|
+
ts = begin
|
|
22
|
+
Integer(timestamp.to_s, 10)
|
|
23
|
+
rescue ArgumentError, TypeError
|
|
24
|
+
return :bad_signature
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
return :stale if (now - ts).abs > window
|
|
28
|
+
|
|
29
|
+
expected = Signing.hmac_hex(secret, "#{ts}.#{body}")
|
|
30
|
+
return :bad_signature unless Signing.secure_compare(expected, signature.to_s)
|
|
31
|
+
|
|
32
|
+
:ok
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.sign(secret:, body:, timestamp:)
|
|
36
|
+
Signing.hmac_hex(secret, "#{timestamp}.#{body}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.blank?(v)
|
|
40
|
+
v.nil? || v.to_s.empty?
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
# Deploys and manages the shipped Cloudflare Email Worker via the
|
|
8
|
+
# Cloudflare API directly — no wrangler, no Node, no npm required.
|
|
9
|
+
#
|
|
10
|
+
# Required API token scopes:
|
|
11
|
+
# Account → Workers Scripts → Edit
|
|
12
|
+
#
|
|
13
|
+
# Usage:
|
|
14
|
+
# deployer = Cloudflare::Email::WorkerDeployer.new(
|
|
15
|
+
# account_id: ..., api_token: ...,
|
|
16
|
+
# )
|
|
17
|
+
# deployer.deploy(script_path: "cloudflare-worker/src/index.js")
|
|
18
|
+
# deployer.put_secret("INGRESS_SECRET", "...")
|
|
19
|
+
# deployer.put_secret("RAILS_INGRESS_URL", "https://...")
|
|
20
|
+
class WorkerDeployer
|
|
21
|
+
SCRIPT_NAME_PREFIX = "cloudflare-email-ingress".freeze
|
|
22
|
+
DEFAULT_COMPATIBILITY_DATE = "2026-04-01".freeze
|
|
23
|
+
API_BASE = "https://api.cloudflare.com/client/v4".freeze
|
|
24
|
+
|
|
25
|
+
attr_reader :script_name
|
|
26
|
+
|
|
27
|
+
# Default Worker name includes the current Rails environment so a dev
|
|
28
|
+
# tunnel never stomps on the production Worker's RAILS_INGRESS_URL.
|
|
29
|
+
# Override via `script_name:` for tests or explicit control.
|
|
30
|
+
def self.default_script_name
|
|
31
|
+
env = defined?(Rails) && Rails.respond_to?(:env) ? Rails.env.to_s : ENV["RAILS_ENV"].to_s
|
|
32
|
+
default_script_name_with_env(env)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.default_script_name_with_env(env)
|
|
36
|
+
env = "production" if env.to_s.empty?
|
|
37
|
+
"#{SCRIPT_NAME_PREFIX}-#{env}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(account_id:, api_token:,
|
|
41
|
+
script_name: nil,
|
|
42
|
+
compatibility_date: DEFAULT_COMPATIBILITY_DATE,
|
|
43
|
+
api_base: API_BASE)
|
|
44
|
+
script_name ||= self.class.default_script_name
|
|
45
|
+
raise ArgumentError, "account_id is required" if account_id.to_s.empty?
|
|
46
|
+
raise ArgumentError, "api_token is required" if api_token.to_s.empty?
|
|
47
|
+
|
|
48
|
+
@account_id = account_id
|
|
49
|
+
@api_token = api_token
|
|
50
|
+
@script_name = script_name
|
|
51
|
+
@compatibility_date = compatibility_date
|
|
52
|
+
@api_base = api_base
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Uploads/updates the Worker script. Accepts either `script_path:` (a
|
|
56
|
+
# path to a .js file) or `source:` (the JS source string directly).
|
|
57
|
+
def deploy(script_path: nil, source: nil)
|
|
58
|
+
source ||= File.read(script_path) if script_path
|
|
59
|
+
raise ArgumentError, "must pass script_path: or source:" if source.nil?
|
|
60
|
+
|
|
61
|
+
boundary = "----cf-email-#{SecureRandom.hex(16)}"
|
|
62
|
+
body = build_multipart(boundary, source)
|
|
63
|
+
|
|
64
|
+
request(
|
|
65
|
+
method: :put,
|
|
66
|
+
path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}",
|
|
67
|
+
body: body,
|
|
68
|
+
content_type: "multipart/form-data; boundary=#{boundary}",
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Set/update a Worker secret.
|
|
73
|
+
def put_secret(name, value)
|
|
74
|
+
request(
|
|
75
|
+
method: :put,
|
|
76
|
+
path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/secrets",
|
|
77
|
+
body: JSON.generate({ name: name.to_s, text: value.to_s, type: "secret_text" }),
|
|
78
|
+
content_type: "application/json",
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Delete a Worker secret by name.
|
|
83
|
+
def delete_secret(name)
|
|
84
|
+
request(
|
|
85
|
+
method: :delete,
|
|
86
|
+
path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/secrets/#{name}",
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# True if the Worker script already exists.
|
|
91
|
+
def exists?
|
|
92
|
+
response = raw_request(
|
|
93
|
+
method: :get,
|
|
94
|
+
path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}",
|
|
95
|
+
)
|
|
96
|
+
response.code.to_i == 200
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Delete the Worker. Useful for teardown in tests.
|
|
100
|
+
def delete_script
|
|
101
|
+
request(
|
|
102
|
+
method: :delete,
|
|
103
|
+
path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}",
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def build_multipart(boundary, source)
|
|
110
|
+
metadata = JSON.generate({
|
|
111
|
+
main_module: "index.js",
|
|
112
|
+
compatibility_date: @compatibility_date,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
parts = []
|
|
116
|
+
parts << "--#{boundary}"
|
|
117
|
+
parts << 'Content-Disposition: form-data; name="metadata"'
|
|
118
|
+
parts << "Content-Type: application/json"
|
|
119
|
+
parts << ""
|
|
120
|
+
parts << metadata
|
|
121
|
+
parts << "--#{boundary}"
|
|
122
|
+
parts << 'Content-Disposition: form-data; name="index.js"; filename="index.js"'
|
|
123
|
+
parts << "Content-Type: application/javascript+module"
|
|
124
|
+
parts << ""
|
|
125
|
+
parts << source
|
|
126
|
+
parts << "--#{boundary}--"
|
|
127
|
+
# Force binary to avoid encoding-driven header injection by Net::HTTP.
|
|
128
|
+
(parts.join("\r\n") + "\r\n").force_encoding(Encoding::ASCII_8BIT)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def request(method:, path:, body: nil, content_type: "application/json")
|
|
132
|
+
response = raw_request(method: method, path: path, body: body, content_type: content_type)
|
|
133
|
+
handle(response, "#{method.upcase} #{path}")
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def raw_request(method:, path:, body: nil, content_type: "application/json")
|
|
137
|
+
uri = URI.parse("#{@api_base}#{path}")
|
|
138
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
139
|
+
http.use_ssl = uri.scheme == "https"
|
|
140
|
+
http.open_timeout = 30
|
|
141
|
+
http.read_timeout = 60
|
|
142
|
+
|
|
143
|
+
klass = {
|
|
144
|
+
put: Net::HTTP::Put,
|
|
145
|
+
post: Net::HTTP::Post,
|
|
146
|
+
get: Net::HTTP::Get,
|
|
147
|
+
delete: Net::HTTP::Delete,
|
|
148
|
+
}.fetch(method)
|
|
149
|
+
|
|
150
|
+
req = klass.new(uri.request_uri)
|
|
151
|
+
req["Authorization"] = "Bearer #{@api_token}"
|
|
152
|
+
req["Accept"] = "application/json"
|
|
153
|
+
req["Content-Type"] = content_type if body
|
|
154
|
+
req.body = body if body
|
|
155
|
+
|
|
156
|
+
http.request(req)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def handle(response, context)
|
|
160
|
+
status = response.code.to_i
|
|
161
|
+
body = parse(response.body)
|
|
162
|
+
|
|
163
|
+
return body if status.between?(200, 299)
|
|
164
|
+
|
|
165
|
+
errors = body.is_a?(Hash) ? Array(body["errors"]) : []
|
|
166
|
+
message = errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.join("; ")
|
|
167
|
+
message = "HTTP #{status}" if message.empty?
|
|
168
|
+
|
|
169
|
+
raise Error.new(
|
|
170
|
+
"[worker_deployer] #{context} failed: #{message}",
|
|
171
|
+
status: status, response: body,
|
|
172
|
+
)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parse(body)
|
|
176
|
+
return {} if body.nil? || body.empty?
|
|
177
|
+
JSON.parse(body)
|
|
178
|
+
rescue JSON::ParserError
|
|
179
|
+
{ "errors" => [{ "message" => body.to_s[0, 200] }] }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
require "cloudflare/email/version"
|
|
2
|
+
require "cloudflare/email/error"
|
|
3
|
+
require "cloudflare/email/response"
|
|
4
|
+
require "cloudflare/email/signing"
|
|
5
|
+
require "cloudflare/email/credentials"
|
|
6
|
+
require "cloudflare/email/client"
|
|
7
|
+
require "cloudflare/email/secure_message_id"
|
|
8
|
+
|
|
9
|
+
require "cloudflare/email/engine" if defined?(::Rails::Engine)
|