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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +87 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +521 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
  6. data/lib/cloudflare/email/client.rb +216 -0
  7. data/lib/cloudflare/email/credentials.rb +59 -0
  8. data/lib/cloudflare/email/delivery_method.rb +57 -0
  9. data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
  10. data/lib/cloudflare/email/dev_tunnel.rb +97 -0
  11. data/lib/cloudflare/email/doctor.rb +244 -0
  12. data/lib/cloudflare/email/engine.rb +25 -0
  13. data/lib/cloudflare/email/error.rb +20 -0
  14. data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
  15. data/lib/cloudflare/email/provision_route_task.rb +34 -0
  16. data/lib/cloudflare/email/response.rb +61 -0
  17. data/lib/cloudflare/email/routing_provisioner.rb +220 -0
  18. data/lib/cloudflare/email/secure_message_id.rb +89 -0
  19. data/lib/cloudflare/email/send_test.rb +75 -0
  20. data/lib/cloudflare/email/signing.rb +37 -0
  21. data/lib/cloudflare/email/task_base.rb +61 -0
  22. data/lib/cloudflare/email/verification.rb +44 -0
  23. data/lib/cloudflare/email/version.rb +5 -0
  24. data/lib/cloudflare/email/worker_deployer.rb +183 -0
  25. data/lib/cloudflare-email.rb +9 -0
  26. data/lib/generators/cloudflare/email/install_generator.rb +227 -0
  27. data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
  28. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
  29. data/lib/tasks/cloudflare_email.rake +45 -0
  30. data/templates/worker/README.md +37 -0
  31. data/templates/worker/package.json +15 -0
  32. data/templates/worker/src/index.js +73 -0
  33. data/templates/worker/test/index.test.ts +139 -0
  34. data/templates/worker/vitest.config.ts +8 -0
  35. data/templates/worker/wrangler.toml +10 -0
  36. 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,5 @@
1
+ module Cloudflare
2
+ module Email
3
+ VERSION = "0.1.0"
4
+ end
5
+ 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)