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,67 @@
1
+ require "cloudflare/email/verification"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ # ActionMailbox ingress for Cloudflare Email Worker forwards.
6
+ #
7
+ # The shipped Worker template signs each forwarded message with HMAC-SHA256
8
+ # over "{timestamp}.{raw_body}" and sends:
9
+ # X-CF-Email-Timestamp: <unix seconds>
10
+ # X-CF-Email-Signature: <hex digest>
11
+ #
12
+ # Set the shared secret in Rails credentials under cloudflare.ingress_secret
13
+ # (or in the CLOUDFLARE_INGRESS_SECRET env var) and as the Worker secret
14
+ # INGRESS_SECRET via `wrangler secret put INGRESS_SECRET`.
15
+ class IngressController < ActionMailbox::BaseController
16
+ param_encoding :create, "raw_email", Encoding::ASCII_8BIT
17
+
18
+ def create
19
+ ActiveSupport::Notifications.instrument(
20
+ "cloudflare_email.ingress",
21
+ bytes: raw_body.bytesize,
22
+ ) do |payload|
23
+ case Cloudflare::Email::Verification.verify(
24
+ secret: secret,
25
+ body: raw_body,
26
+ timestamp: request.headers["X-CF-Email-Timestamp"],
27
+ signature: request.headers["X-CF-Email-Signature"],
28
+ )
29
+ when :stale
30
+ payload[:result] = :stale
31
+ head :request_timeout
32
+ when :bad_signature
33
+ payload[:result] = :bad_signature
34
+ head :unauthorized
35
+ when :ok
36
+ inbound = ActionMailbox::InboundEmail.create_and_extract_message_id!(raw_body)
37
+ payload[:result] = :ok
38
+ payload[:message_id] = inbound.message_id
39
+ head :ok
40
+ end
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ # Override ActionMailbox::BaseController's default name inference so
47
+ # `config.action_mailbox.ingress = :cloudflare` gates this controller.
48
+ def ingress_name
49
+ :cloudflare
50
+ end
51
+
52
+ def raw_body
53
+ @raw_body ||= begin
54
+ request.body.rewind if request.body.respond_to?(:rewind)
55
+ request.body.read
56
+ end
57
+ end
58
+
59
+ def secret
60
+ @secret ||= begin
61
+ require "cloudflare/email/credentials"
62
+ Cloudflare::Email::Credentials.ingress_secret
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,216 @@
1
+ require "net/http"
2
+ require "json"
3
+ require "uri"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ class Client
8
+ DEFAULT_BASE_URL = "https://api.cloudflare.com/client/v4".freeze
9
+ DEFAULT_RETRIES = 3
10
+ DEFAULT_TIMEOUT = 30
11
+ DEFAULT_BACKOFF = 0.5
12
+ MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
13
+
14
+ RETRYABLE_NETWORK = [
15
+ Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
16
+ Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
17
+ IOError
18
+ ].freeze
19
+
20
+ attr_reader :account_id, :base_url, :retries, :timeout
21
+
22
+ def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
23
+ retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
24
+ initial_backoff: DEFAULT_BACKOFF, max_retry_after: MAX_RETRY_AFTER,
25
+ logger: nil)
26
+ raise ConfigurationError, "account_id is required" if account_id.nil? || account_id.to_s.empty?
27
+ raise ConfigurationError, "api_token is required" if api_token.nil? || api_token.to_s.empty?
28
+
29
+ @account_id = account_id
30
+ @api_token = api_token
31
+ @base_url = base_url
32
+ @retries = retries
33
+ @timeout = timeout
34
+ @initial_backoff = initial_backoff
35
+ @max_retry_after = max_retry_after
36
+ @logger = logger
37
+ end
38
+
39
+ def send(from:, to:, subject:, text: nil, html: nil, cc: nil, bcc: nil,
40
+ reply_to: nil, headers: nil, attachments: nil)
41
+ raise ValidationError, "must provide :text or :html" if text.nil? && html.nil?
42
+
43
+ body = {
44
+ from: normalize_address(from),
45
+ to: wrap(to).map { |addr| normalize_address(addr) },
46
+ subject: subject,
47
+ }
48
+ body[:text] = text if text
49
+ body[:html] = html if html
50
+ body[:cc] = wrap(cc).map { |a| normalize_address(a) } if cc
51
+ body[:bcc] = wrap(bcc).map { |a| normalize_address(a) } if bcc
52
+ body[:reply_to] = normalize_address(reply_to) if reply_to
53
+ body[:headers] = headers if headers
54
+ body[:attachments] = attachments if attachments
55
+
56
+ perform(:send, "/accounts/#{@account_id}/email/sending/send", body)
57
+ end
58
+
59
+ def send_raw(from:, recipients:, mime_message:)
60
+ body = {
61
+ from: extract_address(from),
62
+ recipients: wrap(recipients).map { |r| extract_address(r) },
63
+ mime_message: mime_message,
64
+ }
65
+ perform(:send_raw, "/accounts/#{@account_id}/email/sending/send_raw", body)
66
+ end
67
+
68
+ private
69
+
70
+ # Wrap a value into an array without Hash-to-pair-array conversion.
71
+ def wrap(value)
72
+ case value
73
+ when Array then value
74
+ when nil then []
75
+ else [value]
76
+ end
77
+ end
78
+
79
+ def normalize_address(addr)
80
+ case addr
81
+ when String
82
+ addr
83
+ when Hash
84
+ h = { address: addr[:address] || addr["address"] }
85
+ name = addr[:name] || addr["name"]
86
+ h[:name] = name if name
87
+ raise ValidationError, "address hash requires :address" unless h[:address]
88
+ h
89
+ else
90
+ raise ValidationError, "address must be a String or Hash, got #{addr.class}"
91
+ end
92
+ end
93
+
94
+ def extract_address(addr)
95
+ case addr
96
+ when String then addr
97
+ when Hash then addr[:address] || addr["address"] || raise(ValidationError, "address hash requires :address")
98
+ else raise ValidationError, "address must be a String or Hash, got #{addr.class}"
99
+ end
100
+ end
101
+
102
+ def perform(operation, path, body)
103
+ instrument("cloudflare_email.#{operation}", account_id: @account_id, path: path) do |payload|
104
+ response = request(:post, path, body)
105
+ payload[:status] = response.status
106
+ payload[:message_id] = response.message_id
107
+ response
108
+ end
109
+ end
110
+
111
+ def instrument(name, payload)
112
+ if defined?(ActiveSupport::Notifications)
113
+ ActiveSupport::Notifications.instrument(name, payload) { |p| yield p }
114
+ else
115
+ yield payload
116
+ end
117
+ end
118
+
119
+ def request(method, path, body)
120
+ uri = URI.parse("#{@base_url}#{path}")
121
+ attempts = 0
122
+ backoff = @initial_backoff
123
+
124
+ begin
125
+ attempts += 1
126
+ do_request(method, uri, body)
127
+ rescue *RETRYABLE_NETWORK => e
128
+ raise NetworkError.new(e.message) if attempts > @retries
129
+ log_retry(attempts, e)
130
+ sleep(backoff); backoff *= 2
131
+ retry
132
+ rescue RateLimitError => e
133
+ raise if attempts > @retries
134
+ log_retry(attempts, e)
135
+ sleep(retry_after_from(e, backoff)); backoff *= 2
136
+ retry
137
+ rescue ServerError => e
138
+ raise if attempts > @retries
139
+ log_retry(attempts, e)
140
+ sleep(backoff); backoff *= 2
141
+ retry
142
+ end
143
+ end
144
+
145
+ def retry_after_from(error, fallback_backoff)
146
+ header = error.response.is_a?(Hash) ? error.response["retry_after"] : nil
147
+ value = header || fallback_backoff
148
+ seconds = value.to_f
149
+ return fallback_backoff if seconds <= 0
150
+ [seconds, @max_retry_after].min
151
+ end
152
+
153
+ def do_request(method, uri, body)
154
+ http = Net::HTTP.new(uri.host, uri.port)
155
+ http.use_ssl = (uri.scheme == "https")
156
+ http.open_timeout = @timeout
157
+ http.read_timeout = @timeout
158
+
159
+ req_class = { post: Net::HTTP::Post, get: Net::HTTP::Get }.fetch(method)
160
+ req = req_class.new(uri.request_uri)
161
+ req["Authorization"] = "Bearer #{@api_token}"
162
+ req["Content-Type"] = "application/json"
163
+ req["Accept"] = "application/json"
164
+ req["User-Agent"] = "cloudflare-email-ruby/#{Cloudflare::Email::VERSION}"
165
+ req.body = JSON.generate(body) if body
166
+
167
+ response = http.request(req)
168
+ handle_response(response)
169
+ end
170
+
171
+ def handle_response(response)
172
+ status = response.code.to_i
173
+ body = parse_body(response.body)
174
+ retry_after = response["Retry-After"]
175
+
176
+ # Stash Retry-After on the error response so retry logic can use it.
177
+ body = body.merge("retry_after" => retry_after) if body.is_a?(Hash) && retry_after
178
+
179
+ case status
180
+ when 200..299
181
+ Response.new(body, status: status)
182
+ when 400, 422
183
+ raise ValidationError.new(extract_message(body), status: status, response: body)
184
+ when 401, 403
185
+ raise AuthenticationError.new(extract_message(body), status: status, response: body)
186
+ when 429
187
+ raise RateLimitError.new(extract_message(body), status: status, response: body)
188
+ when 500..599
189
+ raise ServerError.new(extract_message(body), status: status, response: body)
190
+ else
191
+ raise Error.new("unexpected status #{status}: #{extract_message(body)}",
192
+ status: status, response: body)
193
+ end
194
+ end
195
+
196
+ def parse_body(raw)
197
+ return {} if raw.nil? || raw.empty?
198
+ JSON.parse(raw)
199
+ rescue JSON::ParserError
200
+ { "errors" => [{ "message" => raw.to_s[0, 200] }] }
201
+ end
202
+
203
+ def extract_message(body)
204
+ return "unknown error" unless body.is_a?(Hash)
205
+ errors = body["errors"]
206
+ return "unknown error" unless errors.is_a?(Array) && errors.any?
207
+ errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.join("; ")
208
+ end
209
+
210
+ def log_retry(attempt, error)
211
+ return unless @logger
212
+ @logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}: #{error.message}")
213
+ end
214
+ end
215
+ end
216
+ end
@@ -0,0 +1,59 @@
1
+ module Cloudflare
2
+ module Email
3
+ # Unified credential lookup.
4
+ #
5
+ # Precedence:
6
+ # 1. Rails.application.credentials.dig(:cloudflare, key) — encrypted credentials.yml.enc
7
+ # (respects per-environment files: config/credentials/{env}.yml.enc when present)
8
+ # 2. ENV["CLOUDFLARE_#{KEY}"] — env vars, including anything dotenv or foreman loaded from .env
9
+ #
10
+ # Supported keys:
11
+ # account_id — Cloudflare account ID
12
+ # api_token — runtime token used by the delivery method (needs Email Sending: Send)
13
+ # management_token — higher-privilege token used by deploy/provision rake tasks
14
+ # (needs Workers Scripts: Edit, Zone: Read, Email Routing: Edit).
15
+ # Falls back to api_token if not set.
16
+ # ingress_secret — HMAC shared secret between Worker and Rails ingress
17
+ module Credentials
18
+ class << self
19
+ def account_id
20
+ fetch(:account_id)
21
+ end
22
+
23
+ def api_token
24
+ fetch(:api_token)
25
+ end
26
+
27
+ # For deploy_worker, provision_route, and dev tasks. Prefer a
28
+ # dedicated higher-privilege token if the user has split them;
29
+ # otherwise reuse api_token (most single-token setups).
30
+ def management_token
31
+ token = fetch(:management_token)
32
+ token.empty? ? api_token : token
33
+ end
34
+
35
+ def ingress_secret
36
+ fetch(:ingress_secret)
37
+ end
38
+
39
+ # True if the user has set a separate management token.
40
+ def split_tokens?
41
+ !fetch(:management_token).empty?
42
+ end
43
+
44
+ def fetch(key)
45
+ from_rails = rails_credentials_dig(key)
46
+ return from_rails unless from_rails.empty?
47
+ ENV["CLOUDFLARE_#{key.to_s.upcase}"].to_s
48
+ end
49
+
50
+ def rails_credentials_dig(key)
51
+ return "" unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
52
+ Rails.application.credentials.dig(:cloudflare, key).to_s
53
+ rescue StandardError
54
+ ""
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,57 @@
1
+ require "cloudflare/email/client"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ # ActionMailer delivery method. Registered on the :cloudflare symbol by the Engine.
6
+ #
7
+ # Configure in your Rails app:
8
+ #
9
+ # config.action_mailer.delivery_method = :cloudflare
10
+ # config.action_mailer.cloudflare_settings = {
11
+ # account_id: Rails.application.credentials.dig(:cloudflare, :account_id),
12
+ # api_token: Rails.application.credentials.dig(:cloudflare, :api_token),
13
+ # }
14
+ class DeliveryMethod
15
+ attr_accessor :settings
16
+
17
+ def initialize(settings = {})
18
+ @settings = settings
19
+ end
20
+
21
+ def deliver!(mail)
22
+ client = Cloudflare::Email::Client.new(
23
+ account_id: settings.fetch(:account_id),
24
+ api_token: settings.fetch(:api_token),
25
+ base_url: settings[:base_url] || Cloudflare::Email::Client::DEFAULT_BASE_URL,
26
+ retries: settings.fetch(:retries, Cloudflare::Email::Client::DEFAULT_RETRIES),
27
+ timeout: settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT),
28
+ logger: settings[:logger],
29
+ )
30
+
31
+ from_addr = mail.from && mail.from.first
32
+ recipients = collect_recipients(mail)
33
+
34
+ raise Cloudflare::Email::ValidationError, "mail has no :from address" if from_addr.nil?
35
+ raise Cloudflare::Email::ValidationError, "mail has no recipients" if recipients.empty?
36
+
37
+ response = client.send_raw(
38
+ from: from_addr,
39
+ recipients: recipients,
40
+ mime_message: mail.encoded,
41
+ )
42
+
43
+ if response.message_id && mail.respond_to?(:message_id=)
44
+ mail.message_id = response.message_id
45
+ end
46
+
47
+ response
48
+ end
49
+
50
+ private
51
+
52
+ def collect_recipients(mail)
53
+ [mail.to, mail.cc, mail.bcc].compact.flatten.uniq
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,62 @@
1
+ require "cloudflare/email/task_base"
2
+ require "cloudflare/email/worker_deployer"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # `bin/rails cloudflare:email:deploy_worker` — uploads the Worker
7
+ # script + both secrets via the Cloudflare API. No wrangler required.
8
+ class DeployWorkerTask < TaskBase
9
+ def self.call(script_path: nil, ingress_url: nil, io: $stdout)
10
+ new(io: io, script_path: script_path, ingress_url: ingress_url).call
11
+ end
12
+
13
+ protected
14
+
15
+ def run
16
+ require_value!(account_id, "cloudflare.account_id")
17
+ require_value!(management_token, "cloudflare.api_token (or cloudflare.management_token)")
18
+ require_value!(ingress_secret, "cloudflare.ingress_secret — run the installer first")
19
+
20
+ path = script_path
21
+ raise "Worker script not found at #{path} — re-run `bin/rails g cloudflare:email:install`" unless File.exist?(path)
22
+
23
+ deployer = Cloudflare::Email::WorkerDeployer.new(
24
+ account_id: account_id, api_token: management_token,
25
+ )
26
+
27
+ say " Deploying Worker '#{deployer.script_name}'..."
28
+ deployer.deploy(script_path: path)
29
+ say " ✓ Worker script deployed"
30
+
31
+ deployer.put_secret("INGRESS_SECRET", ingress_secret)
32
+ say " ✓ INGRESS_SECRET set"
33
+
34
+ if url.to_s.empty?
35
+ say " (skipping RAILS_INGRESS_URL — pass URL=https://... or set RAILS_INGRESS_URL env var)"
36
+ else
37
+ deployer.put_secret("RAILS_INGRESS_URL", url)
38
+ say " ✓ RAILS_INGRESS_URL set to #{url}"
39
+ end
40
+
41
+ say ""
42
+ say " Next: route an address to Worker '#{deployer.script_name}' — either via"
43
+ say " `bin/rails cloudflare:email:provision_route ADDRESS=...` or in the dashboard."
44
+ end
45
+
46
+ private
47
+
48
+ def script_path
49
+ opts[:script_path] || default_script_path
50
+ end
51
+
52
+ def url
53
+ opts[:ingress_url] || ENV["RAILS_INGRESS_URL"].to_s
54
+ end
55
+
56
+ def default_script_path
57
+ candidates = ["cloudflare-worker/src/index.js", "cloudflare-worker/src/index.ts"]
58
+ candidates.find { |p| File.exist?(p) } || candidates.first
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,97 @@
1
+ require "cloudflare/email/worker_deployer"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ # `bin/rails cloudflare:email:dev` — spins up a cloudflared tunnel and
6
+ # updates the deployed Worker's RAILS_INGRESS_URL secret so inbound mail
7
+ # can flow through to this local Rails server. No wrangler required.
8
+ class DevTunnel
9
+ INGRESS_PATH = "/rails/action_mailbox/cloudflare/inbound_emails".freeze
10
+
11
+ def self.call(port: 3000, io: $stdout)
12
+ new(port: port, io: io).call
13
+ end
14
+
15
+ def initialize(port:, io: $stdout)
16
+ @port = port
17
+ @io = io
18
+ @tunnel_pid = nil
19
+ end
20
+
21
+ def call
22
+ check_prerequisites
23
+
24
+ require "cloudflare/email/credentials"
25
+ deployer = Cloudflare::Email::WorkerDeployer.new(
26
+ account_id: Cloudflare::Email::Credentials.account_id,
27
+ api_token: Cloudflare::Email::Credentials.management_token,
28
+ )
29
+
30
+ start_tunnel
31
+ tunnel_url = wait_for_tunnel_url
32
+ @io.puts " Tunnel: #{tunnel_url}"
33
+
34
+ ingress_url = "#{tunnel_url}#{INGRESS_PATH}"
35
+ deployer.put_secret("RAILS_INGRESS_URL", ingress_url)
36
+ @io.puts " Worker '#{deployer.script_name}' RAILS_INGRESS_URL updated → #{ingress_url}"
37
+ @io.puts ""
38
+ @io.puts " Send mail to your routed address; it'll land in this Rails app."
39
+ @io.puts " Ctrl-C to stop."
40
+ @io.puts ""
41
+
42
+ trap("INT") { cleanup; exit 0 }
43
+ trap("TERM") { cleanup; exit 0 }
44
+
45
+ # Stay alive so the tunnel keeps running. The user stops us with Ctrl-C.
46
+ sleep
47
+ ensure
48
+ cleanup
49
+ end
50
+
51
+ private
52
+
53
+ def check_prerequisites
54
+ unless system("command -v cloudflared >/dev/null 2>&1")
55
+ raise "cloudflared not found in PATH — install from https://developers.cloudflare.com/cloudflared/"
56
+ end
57
+ require "cloudflare/email/credentials"
58
+ if Cloudflare::Email::Credentials.account_id.empty? ||
59
+ Cloudflare::Email::Credentials.management_token.empty?
60
+ raise "Missing cloudflare.account_id or cloudflare.api_token in credentials " \
61
+ "(or CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN env vars)"
62
+ end
63
+ end
64
+
65
+ def start_tunnel
66
+ @io.puts " Starting cloudflared tunnel on :#{@port}..."
67
+ @tunnel_log = File.open("/tmp/cloudflare-email-dev-tunnel.log", "w")
68
+ @tunnel_pid = spawn(
69
+ "cloudflared", "tunnel", "--url", "http://127.0.0.1:#{@port}",
70
+ out: @tunnel_log, err: @tunnel_log,
71
+ )
72
+ end
73
+
74
+ def wait_for_tunnel_url
75
+ deadline = Time.now + 30
76
+ while Time.now < deadline
77
+ sleep 0.5
78
+ log = File.read("/tmp/cloudflare-email-dev-tunnel.log") rescue ""
79
+ if (match = log.match(%r{https://[a-z0-9\-]+\.trycloudflare\.com}))
80
+ return match[0]
81
+ end
82
+ end
83
+ raise "Timed out waiting for cloudflared to return a tunnel URL"
84
+ end
85
+
86
+ def cleanup
87
+ if @tunnel_pid
88
+ Process.kill("TERM", @tunnel_pid) rescue nil
89
+ Process.wait(@tunnel_pid) rescue nil
90
+ @tunnel_pid = nil
91
+ end
92
+ @tunnel_log&.close
93
+ end
94
+
95
+ end
96
+ end
97
+ end