cloudflare-email 0.2.0 → 0.3.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 +4 -4
- data/CHANGELOG.md +76 -0
- data/README.md +34 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +18 -8
- data/docs/mailboxes.md +67 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/troubleshooting.md +4 -3
- data/docs/upgrading-0.2.md +5 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +13 -1
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +45 -10
- data/lib/cloudflare/email/delivery_method.rb +2 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
- data/lib/cloudflare/email/engine.rb +5 -0
- data/lib/cloudflare/email/envelope.rb +2 -2
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/models.rb +19 -2
- data/lib/cloudflare/email/mailboxes/service.rb +99 -15
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/verification.rb +17 -9
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +36 -5
- data/lib/generators/cloudflare/email/install_generator.rb +6 -5
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/tasks/cloudflare_email.rake +2 -1
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +180 -15
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +1 -1
- data/templates/worker/src/index.js +331 -38
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +78 -20
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +50 -0
- metadata +49 -3
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
require "timeout"
|
|
5
|
+
require "cloudflare/email/endpoint"
|
|
6
|
+
|
|
7
|
+
module Cloudflare
|
|
8
|
+
module Email
|
|
9
|
+
# A bounded, GET-only snapshot of provider configuration. This deliberately
|
|
10
|
+
# does not inherit RoutingProvisioner: inspecting must never repair routing.
|
|
11
|
+
class RoutingDiagnostics
|
|
12
|
+
API_BASE = "https://api.cloudflare.com/client/v4".freeze
|
|
13
|
+
MAX_BYTES = 1_048_576
|
|
14
|
+
MAX_PAGES = 50
|
|
15
|
+
LIMITATIONS = "Configuration snapshot only: does not prove public DNS propagation, live delivery, Worker destination, or application acceptance.".freeze
|
|
16
|
+
class ReadError < StandardError; end
|
|
17
|
+
|
|
18
|
+
def initialize(api_token:, api_base: API_BASE)
|
|
19
|
+
raise ArgumentError, "api_token is required" if api_token.to_s.empty?
|
|
20
|
+
@api_token = api_token
|
|
21
|
+
@api_base = Endpoint.parse(api_base).to_s.delete_suffix("/")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Status is pass only if every requested configuration check passes.
|
|
25
|
+
# Unknown means inspection could not establish readiness, not success.
|
|
26
|
+
def check(address:, worker_name:, account_id: nil)
|
|
27
|
+
unless address.is_a?(String) && address.bytesize <= 254 && address.match?(/\A[^\s@]+@(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z0-9-]+\z/)
|
|
28
|
+
raise ArgumentError, "address must be a full email address with a DNS domain"
|
|
29
|
+
end
|
|
30
|
+
raise ArgumentError, "worker_name is required" if worker_name.to_s.empty?
|
|
31
|
+
domain = address.split("@", 2).last.downcase
|
|
32
|
+
address = "#{address.split('@', 2).first}@#{domain}"
|
|
33
|
+
checks = []
|
|
34
|
+
zone = inspect_check(checks, "zone") { find_zone(domain) }
|
|
35
|
+
if zone
|
|
36
|
+
if account_id
|
|
37
|
+
actual = zone["account"].is_a?(Hash) ? zone["account"]["id"] : nil
|
|
38
|
+
add(checks, "account", actual.nil? ? "unknown" : (actual == account_id ? "pass" : "fail"),
|
|
39
|
+
actual.nil? ? "Zone account was not returned." : (actual == account_id ? "Zone belongs to the expected account." : "Zone belongs to a different account."))
|
|
40
|
+
end
|
|
41
|
+
zone_id = URI.encode_www_form_component(zone.fetch("id"))
|
|
42
|
+
inspect_check(checks, "routing") { routing_check(checks, zone_id) }
|
|
43
|
+
inspect_check(checks, "dns") { dns_check(checks, zone_id, domain) }
|
|
44
|
+
inspect_check(checks, "route") { route_check(checks, zone_id, address, worker_name) }
|
|
45
|
+
else
|
|
46
|
+
%w[routing dns route].each { |name| add(checks, name, "unknown", "Cannot inspect without a unique zone.") }
|
|
47
|
+
end
|
|
48
|
+
statuses = checks.map { |item| item[:status] }
|
|
49
|
+
{ status: statuses.include?("fail") ? "fail" : (statuses.include?("unknown") ? "unknown" : "pass"),
|
|
50
|
+
checks: checks, limitations: LIMITATIONS }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def add(checks, name, status, message)
|
|
56
|
+
checks << { name: name, status: status, message: message }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def inspect_check(checks, name)
|
|
60
|
+
value = yield
|
|
61
|
+
add(checks, name, "pass", "Found a unique containing Cloudflare zone.") if name == "zone" && value
|
|
62
|
+
add(checks, name, "fail", "No containing Cloudflare zone was found.") if name == "zone" && !value
|
|
63
|
+
value
|
|
64
|
+
rescue ReadError => e
|
|
65
|
+
add(checks, name, "unknown", e.message)
|
|
66
|
+
nil
|
|
67
|
+
rescue StandardError
|
|
68
|
+
add(checks, name, "unknown", "Provider configuration could not be interpreted safely.")
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def find_zone(domain)
|
|
73
|
+
parts = domain.split(".")
|
|
74
|
+
(0..parts.size - 2).each do |index|
|
|
75
|
+
candidate = parts[index..].join(".")
|
|
76
|
+
zones = pages("/zones?name=#{URI.encode_www_form_component(candidate)}")
|
|
77
|
+
next if zones.empty?
|
|
78
|
+
unless zones.size == 1 && zones.first["name"].to_s.downcase == candidate && zones.first["id"].is_a?(String)
|
|
79
|
+
raise ReadError, "Zone lookup was ambiguous or incomplete."
|
|
80
|
+
end
|
|
81
|
+
return zones.first
|
|
82
|
+
end
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def routing_check(checks, zone_id)
|
|
87
|
+
result = get("/zones/#{zone_id}/email/routing")["result"]
|
|
88
|
+
enabled = result.is_a?(Hash) ? result["enabled"] : nil
|
|
89
|
+
status = enabled == true ? "pass" : (enabled == false ? "fail" : "unknown")
|
|
90
|
+
add(checks, "routing", status, enabled == true ? "Zone Email Routing is enabled." : "Zone Email Routing is disabled or its enabled flag is unavailable.")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def dns_check(checks, zone_id, domain)
|
|
94
|
+
records = pages("/zones/#{zone_id}/dns_records?name=#{URI.encode_www_form_component(domain)}")
|
|
95
|
+
# Defensively enforce exact name even when the provider returns parent
|
|
96
|
+
# records. A parent's Google MX must not invalidate a receiving subdomain.
|
|
97
|
+
records = records.select { |record| record["name"].to_s.downcase.delete_suffix(".") == domain }
|
|
98
|
+
mx = records.select { |r| r["type"] == "MX" }.map { |r| r["content"].to_s.downcase.delete_suffix(".") }.uniq.sort
|
|
99
|
+
spf = records.select { |r| r["type"] == "TXT" }.map { |r| r["content"].to_s.delete('"') }.select { |v| v.match?(/\Av=spf1(?:\s|\z)/i) }
|
|
100
|
+
mx_ok = mx == (1..3).map { |n| "route#{n}.mx.cloudflare.net" }
|
|
101
|
+
# Do not declare an include useful when an earlier `all` terminates SPF.
|
|
102
|
+
terms = spf.size == 1 ? spf.first.split.drop(1) : []
|
|
103
|
+
include_index = terms.index("include:_spf.mx.cloudflare.net")
|
|
104
|
+
all_index = terms.index { |term| term.match?(/\A[+~?-]?all\z/i) }
|
|
105
|
+
spf_ok = include_index && (!all_index || include_index < all_index)
|
|
106
|
+
add(checks, "dns", mx_ok && spf_ok ? "pass" : "fail", mx_ok && spf_ok ?
|
|
107
|
+
"Exact receiving domain has Cloudflare MX records and one SPF record including Cloudflare." :
|
|
108
|
+
"Exact receiving domain needs Cloudflare MX records and a single SPF record with an effective Cloudflare include; records may be missing or conflicting.")
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def route_check(checks, zone_id, address, worker_name)
|
|
112
|
+
rules = pages("/zones/#{zone_id}/email/routing/rules")
|
|
113
|
+
matching = []
|
|
114
|
+
rules.each do |rule|
|
|
115
|
+
next if rule["enabled"] == false
|
|
116
|
+
matchers = rule["matchers"]
|
|
117
|
+
unless matchers.is_a?(Array) && matchers.size == 1 && matchers.first.is_a?(Hash)
|
|
118
|
+
raise ReadError, "Unsupported rule matchers prevent establishing which rule wins."
|
|
119
|
+
end
|
|
120
|
+
matcher = matchers.first
|
|
121
|
+
unless matcher["type"] == "literal" && matcher["field"] == "to" && matcher["value"].is_a?(String)
|
|
122
|
+
raise ReadError, "Unsupported rule matchers prevent establishing which rule wins."
|
|
123
|
+
end
|
|
124
|
+
next unless matcher["value"].casecmp?(address)
|
|
125
|
+
raise ReadError, "A matching rule has an unknown enabled state." unless rule["enabled"] == true
|
|
126
|
+
matching << rule
|
|
127
|
+
end
|
|
128
|
+
if matching.size > 1
|
|
129
|
+
raise ReadError, "Multiple explicit rules match; their priority or tie ordering has not been verified."
|
|
130
|
+
end
|
|
131
|
+
if matching.one?
|
|
132
|
+
selected = matching.first
|
|
133
|
+
source = "Explicit address rule (takes precedence over catch-all)"
|
|
134
|
+
else
|
|
135
|
+
selected = get("/zones/#{zone_id}/email/routing/rules/catch_all")["result"]
|
|
136
|
+
unless selected.is_a?(Hash) && selected["matchers"] == [{ "type" => "all" }]
|
|
137
|
+
raise ReadError, "Catch-all settings are incomplete or use unsupported matchers."
|
|
138
|
+
end
|
|
139
|
+
source = "Catch-all rule"
|
|
140
|
+
end
|
|
141
|
+
unless [true, false].include?(selected["enabled"])
|
|
142
|
+
raise ReadError, "Selected rule has an unknown enabled state."
|
|
143
|
+
end
|
|
144
|
+
unless selected["enabled"]
|
|
145
|
+
return add(checks, "route", "fail", "No enabled explicit route or catch-all accepts this address.")
|
|
146
|
+
end
|
|
147
|
+
actions = selected["actions"]
|
|
148
|
+
unless actions.is_a?(Array) && actions.all? { |action| action.is_a?(Hash) }
|
|
149
|
+
raise ReadError, "Selected rule actions were not returned."
|
|
150
|
+
end
|
|
151
|
+
correct = actions.size == 1 && actions.first["type"] == "worker" && actions.first["value"] == [worker_name]
|
|
152
|
+
add(checks, "route", correct ? "pass" : "fail", correct ?
|
|
153
|
+
"#{source} targets the expected Worker." : "#{source} does not exclusively target the expected Worker (it may drop, forward, or target another Worker).")
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def pages(path)
|
|
157
|
+
records = []
|
|
158
|
+
1.upto(MAX_PAGES) do |page|
|
|
159
|
+
data = get("#{path}#{path.include?('?') ? '&' : '?'}per_page=50&page=#{page}")
|
|
160
|
+
batch = data["result"]
|
|
161
|
+
raise ReadError, "Provider returned an invalid list." unless batch.is_a?(Array) && batch.all? { |item| item.is_a?(Hash) }
|
|
162
|
+
records.concat(batch)
|
|
163
|
+
total = data.dig("result_info", "total_pages")
|
|
164
|
+
if total && (!total.is_a?(Integer) || total.negative? || (total.zero? && batch.any?))
|
|
165
|
+
raise ReadError, "Provider returned invalid pagination."
|
|
166
|
+
end
|
|
167
|
+
return records if total ? page >= total : batch.size < 50
|
|
168
|
+
raise ReadError, "Provider pagination ended before all pages were available." if batch.empty?
|
|
169
|
+
end
|
|
170
|
+
raise ReadError, "Provider pagination exceeds the inspection limit."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def get(path)
|
|
174
|
+
uri = URI.parse("#{@api_base}#{path}")
|
|
175
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
176
|
+
http.use_ssl = uri.scheme == "https"
|
|
177
|
+
http.open_timeout = 5
|
|
178
|
+
http.read_timeout = 10
|
|
179
|
+
request = Net::HTTP::Get.new(uri.request_uri)
|
|
180
|
+
request["Authorization"] = "Bearer #{@api_token}"
|
|
181
|
+
request["Accept"] = "application/json"
|
|
182
|
+
body = +""
|
|
183
|
+
# read_timeout alone does not bound a peer streaming tiny chunks.
|
|
184
|
+
Timeout.timeout(20) do
|
|
185
|
+
http.request(request) do |response|
|
|
186
|
+
raise ReadError, "Provider request failed (HTTP #{response.code.to_i}); check token scopes and configuration." unless response.code.to_i.between?(200, 299)
|
|
187
|
+
response.read_body do |chunk|
|
|
188
|
+
raise ReadError, "Provider response exceeds the inspection size limit." if body.bytesize + chunk.bytesize > MAX_BYTES
|
|
189
|
+
body << chunk
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
data = JSON.parse(body)
|
|
194
|
+
unless data.is_a?(Hash) && data["success"] == true && data.key?("result")
|
|
195
|
+
raise ReadError, "Provider response did not contain a successful result."
|
|
196
|
+
end
|
|
197
|
+
data
|
|
198
|
+
rescue ReadError
|
|
199
|
+
raise
|
|
200
|
+
rescue StandardError
|
|
201
|
+
# Never surface raw provider bodies, URLs, credentials, or socket errors.
|
|
202
|
+
raise ReadError, "Provider request could not be read; check connectivity, token scopes, and response format."
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "cloudflare/email/signing"
|
|
2
2
|
require "cloudflare/email/envelope"
|
|
3
|
+
require "cloudflare/email/provider_metadata"
|
|
3
4
|
|
|
4
5
|
module Cloudflare
|
|
5
6
|
module Email
|
|
@@ -13,20 +14,22 @@ module Cloudflare
|
|
|
13
14
|
# X-CF-Email-Signature: <hex digest>
|
|
14
15
|
# X-CF-Email-Signature-Version: 2
|
|
15
16
|
# X-CF-Email-Envelope: <unpadded base64url JSON from/to>
|
|
16
|
-
#
|
|
17
|
+
# Custom integrations may use v3, adding X-CF-Email-Metadata and signing
|
|
18
|
+
# "v3.{timestamp}.{encoded_envelope}.{encoded_metadata}.{raw_body}".
|
|
19
|
+
# v2 rejects metadata headers; v3 requires them. Both require the envelope.
|
|
17
20
|
module Verification
|
|
18
21
|
DEFAULT_WINDOW = 5 * 60 # seconds
|
|
19
22
|
|
|
20
23
|
# Returns :ok, :bad_signature, or :stale.
|
|
21
24
|
# Returns :bad_signature for any malformed input.
|
|
22
|
-
def self.verify(secret:, body:, timestamp:, signature:, version: nil, envelope: nil, window: DEFAULT_WINDOW, now: Time.now.to_i)
|
|
25
|
+
def self.verify(secret:, body:, timestamp:, signature:, version: nil, envelope: nil, metadata: nil, window: DEFAULT_WINDOW, now: Time.now.to_i)
|
|
23
26
|
return :bad_signature if blank?(body)
|
|
24
27
|
timestamp = timestamp.to_s if timestamp.is_a?(Integer)
|
|
25
28
|
preflight = verify_headers(secret: secret, timestamp: timestamp, signature: signature,
|
|
26
|
-
version: version, envelope: envelope, window: window, now: now)
|
|
29
|
+
version: version, envelope: envelope, metadata: metadata, window: window, now: now)
|
|
27
30
|
return preflight unless preflight == :ok
|
|
28
31
|
|
|
29
|
-
expected = sign(secret: secret, body: body, timestamp:
|
|
32
|
+
expected = sign(secret: secret, body: body, timestamp: timestamp, version: version, envelope: envelope, metadata: metadata)
|
|
30
33
|
return :bad_signature unless Signing.secure_compare(expected, signature)
|
|
31
34
|
|
|
32
35
|
:ok
|
|
@@ -34,11 +37,12 @@ module Cloudflare
|
|
|
34
37
|
|
|
35
38
|
# Reject malformed/stale requests before the controller reads MIME bytes.
|
|
36
39
|
# This is only a preflight: authentication still requires verify(body: ...).
|
|
37
|
-
def self.verify_headers(secret:, timestamp:, signature:, version:, envelope:, window: DEFAULT_WINDOW, now: Time.now.to_i)
|
|
40
|
+
def self.verify_headers(secret:, timestamp:, signature:, version:, envelope:, metadata: nil, window: DEFAULT_WINDOW, now: Time.now.to_i)
|
|
38
41
|
return :bad_signature if blank?(secret)
|
|
39
42
|
return :bad_signature unless timestamp.is_a?(String) && /\A[0-9]{1,20}\z/.match?(timestamp)
|
|
40
43
|
return :bad_signature unless signature.is_a?(String) && /\A[0-9a-f]{64}\z/.match?(signature)
|
|
41
|
-
return :bad_signature unless
|
|
44
|
+
return :bad_signature unless Envelope.decode(envelope)
|
|
45
|
+
return :bad_signature unless valid_metadata_version?(version, metadata)
|
|
42
46
|
|
|
43
47
|
ts = begin
|
|
44
48
|
Integer(timestamp.to_s, 10)
|
|
@@ -51,13 +55,17 @@ module Cloudflare
|
|
|
51
55
|
:ok
|
|
52
56
|
end
|
|
53
57
|
|
|
54
|
-
def self.sign(secret:, body:, timestamp:, envelope:, version: "2")
|
|
55
|
-
raise ArgumentError, "
|
|
58
|
+
def self.sign(secret:, body:, timestamp:, envelope:, version: "2", metadata: nil)
|
|
59
|
+
raise ArgumentError, "invalid signature version or metadata" unless valid_metadata_version?(version, metadata)
|
|
56
60
|
raise ArgumentError, "invalid SMTP envelope" unless Envelope.decode(envelope)
|
|
57
|
-
prefix = "v2.#{timestamp}.#{envelope}."
|
|
61
|
+
prefix = version == "3" ? "v3.#{timestamp}.#{envelope}.#{metadata}." : "v2.#{timestamp}.#{envelope}."
|
|
58
62
|
Signing.hmac_hex(secret, prefix.b + body.b)
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
def self.valid_metadata_version?(version, metadata)
|
|
66
|
+
(version == "2" && metadata.nil?) || (version == "3" && ProviderMetadata.decode(metadata))
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def self.blank?(v)
|
|
62
70
|
v.nil? || v.to_s.empty?
|
|
63
71
|
end
|
|
@@ -55,12 +55,20 @@ module Cloudflare
|
|
|
55
55
|
|
|
56
56
|
# Uploads/updates the Worker script. Accepts either `script_path:` (a
|
|
57
57
|
# path to a .js file) or `source:` (the JS source string directly).
|
|
58
|
-
|
|
58
|
+
# The Rails deploy task passes delivery_mode: "durable" and validates
|
|
59
|
+
# existing infrastructure. Low-level callers managing custom Workers may
|
|
60
|
+
# omit it to preserve their configured transport without changing it.
|
|
61
|
+
def deploy(script_path: nil, source: nil, delivery_mode: nil)
|
|
59
62
|
source ||= File.read(script_path) if script_path
|
|
60
63
|
raise ArgumentError, "must pass script_path: or source:" if source.nil?
|
|
61
64
|
|
|
65
|
+
if delivery_mode
|
|
66
|
+
raise ArgumentError, "delivery_mode must be durable or direct" unless %w[durable direct].include?(delivery_mode)
|
|
67
|
+
verify_durable_infrastructure! if delivery_mode == "durable"
|
|
68
|
+
end
|
|
69
|
+
|
|
62
70
|
boundary = "----cf-email-#{SecureRandom.hex(16)}"
|
|
63
|
-
body = build_multipart(boundary, source)
|
|
71
|
+
body = build_multipart(boundary, source, delivery_mode)
|
|
64
72
|
|
|
65
73
|
request(
|
|
66
74
|
method: :put,
|
|
@@ -108,11 +116,34 @@ module Cloudflare
|
|
|
108
116
|
|
|
109
117
|
private
|
|
110
118
|
|
|
111
|
-
def
|
|
112
|
-
|
|
119
|
+
def verify_durable_infrastructure!
|
|
120
|
+
settings = request(method: :get, path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/settings")
|
|
121
|
+
bindings = Array(settings.dig("result", "bindings"))
|
|
122
|
+
configured = bindings.any? { |b| b["name"] == "INBOUND_EMAIL_STORE" && b["type"] == "r2_bucket" } &&
|
|
123
|
+
bindings.any? { |b| b["name"] == "INBOUND_EMAIL_QUEUE" && b["type"] == "queue" }
|
|
124
|
+
schedules = request(method: :get, path: "/accounts/#{@account_id}/workers/scripts/#{@script_name}/schedules")
|
|
125
|
+
configured &&= Array(schedules.dig("result", "schedules")).any? { |s| s["cron"] == "* * * * *" }
|
|
126
|
+
return if configured
|
|
127
|
+
|
|
128
|
+
raise ConfigurationError, "Provision durable R2, Queue producer/consumer and minute schedule using the generated Worker docs/durable-inbound.md before deploying; use INBOUND_DELIVERY_MODE=direct only for explicit direct fallback"
|
|
129
|
+
rescue Error => error
|
|
130
|
+
raise unless error.status == 404
|
|
131
|
+
|
|
132
|
+
raise ConfigurationError, "Provision the durable Worker with Wrangler first (see Worker docs/durable-inbound.md); use INBOUND_DELIVERY_MODE=direct only for explicit direct fallback"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def build_multipart(boundary, source, delivery_mode)
|
|
136
|
+
settings = {
|
|
113
137
|
main_module: "index.js",
|
|
114
138
|
compatibility_date: @compatibility_date,
|
|
115
|
-
|
|
139
|
+
# API script uploads must not remove provisioned storage, queue or
|
|
140
|
+
# secrets. In particular a direct rollback still drains pending mail.
|
|
141
|
+
keep_bindings: %w[secret_text plain_text r2_bucket queue],
|
|
142
|
+
}
|
|
143
|
+
if delivery_mode
|
|
144
|
+
settings[:bindings] = [{ type: "plain_text", name: "INBOUND_DELIVERY_MODE", text: delivery_mode }]
|
|
145
|
+
end
|
|
146
|
+
metadata = JSON.generate(settings)
|
|
116
147
|
|
|
117
148
|
parts = []
|
|
118
149
|
parts << "--#{boundary}"
|
|
@@ -102,8 +102,8 @@ module Cloudflare
|
|
|
102
102
|
should_deploy = options[:deploy_worker]
|
|
103
103
|
if should_deploy.nil?
|
|
104
104
|
say ""
|
|
105
|
-
say "
|
|
106
|
-
say "
|
|
105
|
+
say " First provision durable storage, Queue and cron using the generated Worker docs/durable-inbound.md."
|
|
106
|
+
say " After the initial Wrangler deploy, Ruby uploads preserve those infrastructure bindings."
|
|
107
107
|
say " Run `RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}`"
|
|
108
108
|
say " after `bin/rails credentials:edit`."
|
|
109
109
|
say ""
|
|
@@ -121,6 +121,7 @@ module Cloudflare
|
|
|
121
121
|
@ingress_secret = SecureRandom.hex(32)
|
|
122
122
|
|
|
123
123
|
inside options[:worker_dir] do
|
|
124
|
+
say "Create the R2 bucket and Queue in docs/durable-inbound.md before deploying. Customize wrangler.toml names if needed."
|
|
124
125
|
run "npm ci", abort_on_failure: true
|
|
125
126
|
|
|
126
127
|
ingress_url = ask("Rails ingress URL? (e.g. https://yourapp.com#{ingress_path})")
|
|
@@ -168,10 +169,10 @@ module Cloudflare
|
|
|
168
169
|
|
|
169
170
|
if options[:inbound] && !@worker_deployed
|
|
170
171
|
say " 4. Deploy the Worker (pick one):"
|
|
171
|
-
say " #
|
|
172
|
+
say " # First follow Worker docs/durable-inbound.md to provision R2, Queue and cron."
|
|
172
173
|
say " RAILS_ENV=production bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument} URL=https://yourapp.com#{ingress_path}"
|
|
173
174
|
say ""
|
|
174
|
-
say " #
|
|
175
|
+
say " # Initial infrastructure deploy (after provisioning resources):"
|
|
175
176
|
say " cd #{options[:worker_dir]}"
|
|
176
177
|
say " npm ci"
|
|
177
178
|
say " npm run deploy -- --env production"
|
|
@@ -182,7 +183,7 @@ module Cloudflare
|
|
|
182
183
|
|
|
183
184
|
if options[:inbound]
|
|
184
185
|
say " 5. For local dev (tunnels cloudflared to your Worker):"
|
|
185
|
-
say " RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
|
|
186
|
+
say " INBOUND_DELIVERY_MODE=direct RAILS_ENV=development bin/rails cloudflare:email:deploy_worker SCRIPT=#{worker_script_argument}"
|
|
186
187
|
say " RAILS_ENV=development bin/rails cloudflare:email:dev"
|
|
187
188
|
say " Route a separate test address to cloudflare-email-ingress-development."
|
|
188
189
|
say ""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Generators
|
|
7
|
+
class CatchAllGenerator < ::Rails::Generators::Base
|
|
8
|
+
include ::ActiveRecord::Generators::Migration
|
|
9
|
+
namespace "cloudflare:email:mailboxes:catch_all"
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
class_option :tenant_migrations_path, type: :string, default: "db/migrate",
|
|
12
|
+
desc: "Migration directory applied to every tenant database"
|
|
13
|
+
|
|
14
|
+
def copy_migration
|
|
15
|
+
migration_template "add_cloudflare_email_catch_all.rb",
|
|
16
|
+
File.join(options[:tenant_migrations_path], "add_cloudflare_email_catch_all.rb")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
class AddCloudflareEmailCatchAll < ActiveRecord::Migration[7.1]
|
|
2
|
+
def up
|
|
3
|
+
unless column_exists?(:cloudflare_email_addresses, :catch_all)
|
|
4
|
+
add_column :cloudflare_email_addresses, :catch_all, :boolean, null: false, default: false
|
|
5
|
+
end
|
|
6
|
+
unless column_exists?(:cloudflare_email_addresses, :catch_all_evidence)
|
|
7
|
+
add_column :cloudflare_email_addresses, :catch_all_evidence, :text
|
|
8
|
+
end
|
|
9
|
+
unless index_exists?(:cloudflare_email_addresses, :receiving_domain_id, name: "idx_cf_email_domain_catch_all")
|
|
10
|
+
add_index :cloudflare_email_addresses, :receiving_domain_id, unique: true,
|
|
11
|
+
where: "catch_all = TRUE AND state = 'active'", name: "idx_cf_email_domain_catch_all"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def down
|
|
16
|
+
raise ActiveRecord::IrreversibleMigration, "Disable catch-all receiving before an explicit forward schema change"
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb
CHANGED
|
@@ -19,10 +19,14 @@ class CreateCloudflareEmailMailboxes < ActiveRecord::Migration[7.1]
|
|
|
19
19
|
t.string :address, null: false
|
|
20
20
|
t.string :state, null: false, default: "pending"
|
|
21
21
|
t.text :provisioning_evidence
|
|
22
|
+
t.boolean :catch_all, null: false, default: false
|
|
23
|
+
t.text :catch_all_evidence
|
|
22
24
|
t.timestamps
|
|
23
25
|
end
|
|
24
26
|
add_index :cloudflare_email_addresses, :address, unique: true, name: "idx_cf_email_mailbox_address"
|
|
25
27
|
add_index :cloudflare_email_addresses, :mailbox_id, name: "idx_cf_email_address_mailbox"
|
|
28
|
+
add_index :cloudflare_email_addresses, :receiving_domain_id, unique: true,
|
|
29
|
+
where: "catch_all = TRUE AND state = 'active'", name: "idx_cf_email_domain_catch_all"
|
|
26
30
|
|
|
27
31
|
create_table :cloudflare_email_mailbox_messages do |t|
|
|
28
32
|
t.string :tenant_key, null: false
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require "rails/generators"
|
|
2
|
+
require "rails/generators/active_record"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Generators
|
|
7
|
+
class RoutingTrackingGenerator < ::Rails::Generators::Base
|
|
8
|
+
include ::ActiveRecord::Generators::Migration
|
|
9
|
+
namespace "cloudflare:email:routing_tracking"
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
class_option :migrations_path, type: :string, default: "db/migrate"
|
|
12
|
+
|
|
13
|
+
def copy_tracking_migration
|
|
14
|
+
migration_template "create_cloudflare_email_routing_delivery_receipts.rb",
|
|
15
|
+
File.join(options[:migrations_path], "create_cloudflare_email_routing_delivery_receipts.rb")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_initializer
|
|
19
|
+
create_file "config/initializers/cloudflare_email_routing_tracking.rb", <<~RUBY
|
|
20
|
+
# Requires the outbox migration and Rails Active Record encryption keys.
|
|
21
|
+
# This loads the API only; your application schedules analytics polling.
|
|
22
|
+
require "cloudflare/email/active_record/routing_deliveries"
|
|
23
|
+
RUBY
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
class CreateCloudflareEmailRoutingDeliveryReceipts < ActiveRecord::Migration[7.1]
|
|
2
|
+
def up
|
|
3
|
+
create_table :cloudflare_email_routing_delivery_receipts do |t|
|
|
4
|
+
t.references :outbound_delivery, null: false, index: {name: "idx_cf_routing_delivery"},
|
|
5
|
+
foreign_key: {to_table: :cloudflare_email_outbound_deliveries}
|
|
6
|
+
t.string :account_id, null: false
|
|
7
|
+
t.string :zone_id, null: false
|
|
8
|
+
t.string :event_key, null: false
|
|
9
|
+
t.text :payload_json, null: false
|
|
10
|
+
t.string :state, null: false, default: "pending"
|
|
11
|
+
t.datetime :received_at, null: false
|
|
12
|
+
t.datetime :applied_at
|
|
13
|
+
t.timestamps
|
|
14
|
+
end
|
|
15
|
+
add_index :cloudflare_email_routing_delivery_receipts, [:account_id, :event_key], unique: true, name: "idx_cf_routing_identity"
|
|
16
|
+
add_index :cloudflare_email_routing_delivery_receipts, [:account_id, :state, :id], name: "idx_cf_routing_replay"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def down
|
|
20
|
+
raise ActiveRecord::IrreversibleMigration, "Preserve authentic delivery evidence; use a forward fix or a reconciled backup"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -15,7 +15,8 @@ namespace :cloudflare do
|
|
|
15
15
|
desc "Deploy the Worker via Cloudflare API. URL=https://... sets RAILS_INGRESS_URL; SCRIPT=path overrides the Worker source"
|
|
16
16
|
task deploy_worker: :environment do
|
|
17
17
|
require "cloudflare/email/deploy_worker_task"
|
|
18
|
-
exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"], script_path: ENV["SCRIPT"]
|
|
18
|
+
exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"], script_path: ENV["SCRIPT"],
|
|
19
|
+
delivery_mode: ENV.fetch("INBOUND_DELIVERY_MODE", "durable"))
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
desc "Create Cloudflare Email Routing rule: ADDRESS=addr@domain → env-scoped Worker (WORKER=name to override)"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
namespace :cloudflare do
|
|
2
|
+
namespace :email do
|
|
3
|
+
desc "Inspect routing without changes (ADDRESS=addr [WORKER_NAME=name] [ACCOUNT_ID=id]); nonzero if failed or unverified"
|
|
4
|
+
task check_route: :environment do
|
|
5
|
+
require "cloudflare/email/check_route_task"
|
|
6
|
+
exit Cloudflare::Email::CheckRouteTask.call(
|
|
7
|
+
address: ENV["ADDRESS"], worker_name: ENV["WORKER_NAME"] || ENV["WORKER"], account_id: ENV["ACCOUNT_ID"],
|
|
8
|
+
)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Cole
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Cloudflare Email for Rails
|
|
2
|
+
|
|
3
|
+
Receive email in Rails with the [cloudflare-email gem](https://github.com/cole-robertson/cloudflare-email).
|
|
4
|
+
This template stores inbound mail in private R2, delivers it through a Queue,
|
|
5
|
+
and retries from storage when Rails is unavailable. It includes scheduled
|
|
6
|
+
recovery and Worker logs. No React app or extra server is needed.
|
|
7
|
+
|
|
8
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
9
|
+
|
|
10
|
+
## 1. Prepare Rails
|
|
11
|
+
|
|
12
|
+
Install `gem "cloudflare-email", "~> 0.3.0"` and follow the
|
|
13
|
+
[Rails setup guide](https://github.com/cole-robertson/cloudflare-email/blob/main/docs/getting-started.md).
|
|
14
|
+
Deploy Rails with Action Mailbox storage and routing configured before connecting
|
|
15
|
+
real email. Generate a secret with `openssl rand -hex 32` and configure it as
|
|
16
|
+
`cloudflare.ingress_secret` in Rails credentials or `CLOUDFLARE_INGRESS_SECRET`.
|
|
17
|
+
Keep the same value for the deployment form.
|
|
18
|
+
|
|
19
|
+
## 2. Deploy the Worker
|
|
20
|
+
|
|
21
|
+
Click the button and sign in to Cloudflare and GitHub/GitLab as prompted.
|
|
22
|
+
Cloudflare copies this directory into your own repository, provisions the R2
|
|
23
|
+
bucket and Queue, and builds the Worker. Enter:
|
|
24
|
+
|
|
25
|
+
- **RAILS_INGRESS_URL:** your public HTTPS endpoint, normally
|
|
26
|
+
`https://your-app.com/rails/action_mailbox/cloudflare/inbound_emails`.
|
|
27
|
+
- **INGRESS_SECRET:** the exact secret you configured in Rails.
|
|
28
|
+
|
|
29
|
+
Choose distinct Worker, bucket, and Queue names for each app/environment. This
|
|
30
|
+
template has one environment per deployment; deploy separate copies for staging
|
|
31
|
+
and production. R2 and Queues must be available on your account and their usage
|
|
32
|
+
can incur charges. Review Cloudflare's resource and billing prompts.
|
|
33
|
+
|
|
34
|
+
The form uses `npm run deploy`; no `--env` argument is required for this template.
|
|
35
|
+
After deployment, confirm both bindings, the Queue consumer, and the once-per-minute
|
|
36
|
+
cron are present. Keep the bucket private with no lifecycle rule expiring
|
|
37
|
+
`cloudflare-email/pending/` objects. The Worker has no public email-submit HTTP API.
|
|
38
|
+
|
|
39
|
+
## 3. Connect an email address
|
|
40
|
+
|
|
41
|
+
In Cloudflare, enable Email Routing for the intended receiving domain/subdomain
|
|
42
|
+
and complete its required DNS setup. Add an Email Routing rule for your receiving
|
|
43
|
+
address with **Send to a Worker**, selecting the Worker you just deployed.
|
|
44
|
+
The button does not change your MX records, provision addresses in Rails, or
|
|
45
|
+
replace your existing mailbox provider. Use a receiving subdomain when your apex
|
|
46
|
+
domain's email belongs to Google Workspace or Microsoft 365.
|
|
47
|
+
|
|
48
|
+
If using the gem's managed mailboxes, register the receiving mailbox/address in
|
|
49
|
+
Rails too. Tenant support and domain catch-all receiving remain explicit choices.
|
|
50
|
+
Outbound Email Sending credentials and delivery-event subscriptions are separate
|
|
51
|
+
from this inbound Worker setup.
|
|
52
|
+
|
|
53
|
+
## 4. Verify and operate
|
|
54
|
+
|
|
55
|
+
Send to the configured address and verify that Rails retains and routes it.
|
|
56
|
+
In staging, stop Rails, send another message, confirm it remains in R2, restore
|
|
57
|
+
Rails, and confirm one inbox record and removal of the pending object. Verify a
|
|
58
|
+
retry does not duplicate application work. Monitor Worker errors and pending R2
|
|
59
|
+
count/age; Queue depth alone misses mail whose queue retries are exhausted.
|
|
60
|
+
|
|
61
|
+
Read the [durable delivery and recovery guide](https://github.com/cole-robertson/cloudflare-email/blob/main/templates/worker/docs/durable-inbound.md)
|
|
62
|
+
for recovery limits, retention, failure drills, and the explicit direct fallback.
|
|
63
|
+
In this standalone template, configure bindings at the top level and run
|
|
64
|
+
`npm run deploy` without `--env`. Do not remove storage or cron while mail is pending.
|
|
65
|
+
Delivery is at least once, with Rails deduplication; application handlers must
|
|
66
|
+
also tolerate retries. Configure operational alerts in your monitoring service.
|
|
67
|
+
|
|
68
|
+
## Updates and local development
|
|
69
|
+
|
|
70
|
+
Your copied repository belongs to you; gem updates do not update its Worker
|
|
71
|
+
automatically. Review upstream release notes, deploy Rails first, then port Worker
|
|
72
|
+
changes into your copy while preserving your resource names, secrets, and pending
|
|
73
|
+
storage. Keep production resources separate from test resources.
|
|
74
|
+
|
|
75
|
+
Use Node 22.12+ (or a supported newer version): `npm ci`, `npm test`, and
|
|
76
|
+
`npm run check`. For local development, copy `.dev.vars.example` to `.dev.vars`,
|
|
77
|
+
set local values, and run `npm run dev`. Never commit secrets.
|
|
78
|
+
|
|
79
|
+
Upstream maintainers: `templates/worker` is the canonical runtime. Run
|
|
80
|
+
`node script/sync_deploy_template.mjs` from the gem repository after modifying its
|
|
81
|
+
runtime, tests, dependencies, or production bindings. CI rejects drift and tests
|
|
82
|
+
this directory after copying it outside the repository, matching Cloudflare's
|
|
83
|
+
subdirectory deployment model. Edit this README directly; generated files are
|
|
84
|
+
marked by the sync script's file list.
|