cloudflare-email 0.2.0 → 0.4.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 +109 -0
- data/README.md +44 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +211 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +19 -8
- data/docs/mailboxes.md +74 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +75 -0
- data/docs/troubleshooting.md +5 -4
- 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-action-mailbox-core.md +96 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -41
- 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 +6 -13
- data/lib/cloudflare/email/envelope.rb +4 -14
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +126 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
- data/lib/cloudflare/email/mailboxes/models.rb +8 -130
- data/lib/cloudflare/email/mailboxes/service.rb +7 -174
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +9 -0
- data/lib/cloudflare/email/management/configuration.rb +10 -0
- data/lib/cloudflare/email/management/engine.rb +17 -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/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- 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/cloudflare-email.rb +6 -0
- 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 +5 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- 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 +104 -0
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +37 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -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/test/subdomains.test.ts +51 -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 +186 -15
- data/templates/worker/docs/domain-setup.md +198 -0
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +2 -1
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- 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/test/subdomains.test.ts +51 -0
- data/templates/worker/wrangler.toml +50 -0
- metadata +65 -3
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "cloudflare/email/routing_analytics"
|
|
4
|
+
require "cloudflare/email/active_record/outbox"
|
|
5
|
+
|
|
6
|
+
module Cloudflare
|
|
7
|
+
module Email
|
|
8
|
+
module ActiveRecord
|
|
9
|
+
class RoutingDeliveryReceipt < Base
|
|
10
|
+
self.table_name = "cloudflare_email_routing_delivery_receipts"
|
|
11
|
+
belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
|
|
12
|
+
encrypts :payload_json
|
|
13
|
+
attr_readonly :account_id, :zone_id, :event_key, :outbound_delivery_id, :payload_json, :received_at
|
|
14
|
+
|
|
15
|
+
def evidence
|
|
16
|
+
RoutingAnalytics::Evidence.new(JSON.parse(payload_json))
|
|
17
|
+
rescue JSON::ParserError, TypeError
|
|
18
|
+
raise RoutingAnalytics::Error, "Stored Routing analytics evidence is malformed", cause: nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# An opt-in delivery projection, separate from Sending webhook receipts and
|
|
23
|
+
# acceptance reconciliation. No method here can send or authorize a resend.
|
|
24
|
+
class RoutingDeliveries
|
|
25
|
+
class InvalidEvidence < RoutingAnalytics::Error; end
|
|
26
|
+
ReplayResult = Struct.new(:after_id, :processed, :errors, :finished, keyword_init: true)
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def record(delivery:, evidence:)
|
|
30
|
+
unless evidence.is_a?(RoutingAnalytics::Evidence) && delivery.is_a?(OutboundDelivery) && delivery.persisted?
|
|
31
|
+
raise ArgumentError, "A persisted outbound delivery and RoutingAnalytics::Evidence are required"
|
|
32
|
+
end
|
|
33
|
+
if RoutingDeliveryReceipt.connection.transaction_open?
|
|
34
|
+
raise ArgumentError, "record must commit outside an existing transaction before projection"
|
|
35
|
+
end
|
|
36
|
+
validate!(delivery.reload, evidence)
|
|
37
|
+
receipt = RoutingDeliveryReceipt.create_or_find_by!(account_id: evidence.account_id, event_key: evidence.identity) do |row|
|
|
38
|
+
row.outbound_delivery = delivery
|
|
39
|
+
row.zone_id = evidence.zone_id
|
|
40
|
+
row.payload_json = JSON.generate(evidence.payload)
|
|
41
|
+
row.received_at = Time.now.utc
|
|
42
|
+
end
|
|
43
|
+
unless receipt.outbound_delivery_id == delivery.id && receipt.evidence.same_event?(evidence)
|
|
44
|
+
raise InvalidEvidence, "Routing analytics evidence identity conflict"
|
|
45
|
+
end
|
|
46
|
+
receipt
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Returns :applied (including idempotent replay). An existing terminal
|
|
50
|
+
# fact is preserved; inspect recipient.state instead of assuming delivery.
|
|
51
|
+
# Only new recipient changes invoke the same-database callback.
|
|
52
|
+
def apply(receipt)
|
|
53
|
+
unless receipt.is_a?(RoutingDeliveryReceipt) && receipt.persisted?
|
|
54
|
+
raise ArgumentError, "A persisted RoutingDeliveryReceipt is required"
|
|
55
|
+
end
|
|
56
|
+
completed = false
|
|
57
|
+
receipt.with_lock(requires_new: true) do
|
|
58
|
+
if receipt.state == "applied"
|
|
59
|
+
completed = true
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
evidence = receipt.evidence
|
|
63
|
+
unless receipt.account_id == evidence.account_id && receipt.zone_id == evidence.zone_id && receipt.event_key == evidence.identity
|
|
64
|
+
raise InvalidEvidence, "Routing analytics receipt identity mismatch"
|
|
65
|
+
end
|
|
66
|
+
delivery = receipt.outbound_delivery
|
|
67
|
+
delivery.with_lock do
|
|
68
|
+
recipient = validate!(delivery, evidence)
|
|
69
|
+
unless recipient.terminal?
|
|
70
|
+
recipient.update!(state: "delivered", occurred_at: evidence.occurred_at, terminal: true)
|
|
71
|
+
yield delivery, recipient if block_given?
|
|
72
|
+
end
|
|
73
|
+
receipt.update!(state: "applied", applied_at: Time.now.utc)
|
|
74
|
+
completed = true
|
|
75
|
+
end
|
|
76
|
+
# ActiveRecord::Rollback can be swallowed by the nested lock's
|
|
77
|
+
# transaction. Fail before this outer transaction can commit.
|
|
78
|
+
raise ArgumentError, "Projection rolled back instead of completing" unless completed
|
|
79
|
+
end
|
|
80
|
+
raise ArgumentError, "Projection rolled back instead of completing" unless completed
|
|
81
|
+
:applied
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Each failed receipt advances the cursor too. Persist after_id in the
|
|
85
|
+
# host job and reset it to zero after finished; discovery gets its own budget.
|
|
86
|
+
def replay(account_id:, limit: 100, after_id: 0, &on_change)
|
|
87
|
+
unless account_id.is_a?(String) && !account_id.empty? && limit.is_a?(Integer) && (1..1000).cover?(limit) &&
|
|
88
|
+
after_id.is_a?(Integer) && after_id >= 0
|
|
89
|
+
raise ArgumentError, "An account, limit of 1..1000 and nonnegative cursor are required"
|
|
90
|
+
end
|
|
91
|
+
scope = RoutingDeliveryReceipt.where(account_id: account_id, state: "pending").where("id > ?", after_id).order(:id)
|
|
92
|
+
rows = scope.limit(limit).to_a
|
|
93
|
+
errors = []
|
|
94
|
+
rows.each do |receipt|
|
|
95
|
+
apply(receipt, &on_change)
|
|
96
|
+
rescue StandardError => error
|
|
97
|
+
errors << {receipt_id: receipt.id, error: error}
|
|
98
|
+
end
|
|
99
|
+
cursor = rows.last&.id || after_id
|
|
100
|
+
ReplayResult.new(after_id: cursor, processed: rows.length, errors: errors,
|
|
101
|
+
finished: !RoutingDeliveryReceipt.where(account_id: account_id, state: "pending").where("id > ?", cursor).exists?)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def validate!(delivery, evidence)
|
|
107
|
+
ids = OutboundDelivery.where(account_id: evidence.account_id,
|
|
108
|
+
provider_message_id: [evidence.message_id, "<#{evidence.message_id}>"]).limit(2).pluck(:id)
|
|
109
|
+
unless delivery.state == "accepted" && delivery.account_id == evidence.account_id && ids == [delivery.id] &&
|
|
110
|
+
MessageId.normalize(delivery.provider_message_id) == evidence.message_id &&
|
|
111
|
+
delivery.from_address.casecmp?(evidence.event.fetch("from")) && delivery.request_started_at &&
|
|
112
|
+
evidence.occurred_at >= Time.at(delivery.request_started_at.to_i).utc
|
|
113
|
+
raise InvalidEvidence, "Routing analytics requires one matching accepted outbound operation"
|
|
114
|
+
end
|
|
115
|
+
recipients = delivery.outbound_recipients.to_a
|
|
116
|
+
envelope = delivery.recipients
|
|
117
|
+
unless envelope.is_a?(Array) && envelope.one? && recipients.one? &&
|
|
118
|
+
recipients.first.recipient == Outbox.normalize_recipient(envelope.first) &&
|
|
119
|
+
recipients.first.acceptance_state == "accepted"
|
|
120
|
+
raise InvalidEvidence, "Routing analytics requires one accepted saved envelope recipient"
|
|
121
|
+
end
|
|
122
|
+
recipients.first
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
require "cloudflare/email/credentials"
|
|
2
|
+
require "cloudflare/email/routing_diagnostics"
|
|
3
|
+
require "cloudflare/email/worker_deployer"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
class CheckRouteTask
|
|
8
|
+
def self.call(address:, worker_name: nil, account_id: nil, io: $stdout)
|
|
9
|
+
report = RoutingDiagnostics.new(api_token: Credentials.management_token).check(
|
|
10
|
+
address: address, worker_name: worker_name || WorkerDeployer.default_script_name,
|
|
11
|
+
account_id: account_id || Credentials.account_id)
|
|
12
|
+
report[:checks].each { |check| io.puts "#{check[:status].upcase} #{check[:name]}: #{check[:message]}" }
|
|
13
|
+
io.puts report[:limitations]
|
|
14
|
+
report[:status] == "pass" ? 0 : 1
|
|
15
|
+
rescue StandardError
|
|
16
|
+
io.puts "UNKNOWN: Unable to inspect routing. Check ADDRESS, WORKER_NAME, and Cloudflare management credentials."
|
|
17
|
+
1
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -2,6 +2,8 @@ require "net/http"
|
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
4
|
require "time"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "zlib"
|
|
5
7
|
require "cloudflare/email/endpoint"
|
|
6
8
|
|
|
7
9
|
module Cloudflare
|
|
@@ -11,33 +13,47 @@ module Cloudflare
|
|
|
11
13
|
DEFAULT_RETRIES = 3
|
|
12
14
|
DEFAULT_TIMEOUT = 30
|
|
13
15
|
DEFAULT_BACKOFF = 0.5
|
|
16
|
+
DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
|
|
14
17
|
MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
|
|
15
18
|
|
|
16
19
|
RETRYABLE_NETWORK = [
|
|
17
20
|
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
|
|
18
21
|
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
|
|
19
|
-
IOError
|
|
22
|
+
IOError, Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
|
23
|
+
OpenSSL::SSL::SSLError, Zlib::Error
|
|
20
24
|
].freeze
|
|
21
25
|
PRE_SEND_NETWORK = [Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError].freeze
|
|
22
26
|
|
|
23
|
-
attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous
|
|
27
|
+
attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous,
|
|
28
|
+
:total_timeout, :max_response_bytes
|
|
24
29
|
|
|
25
30
|
def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
|
|
26
31
|
retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
|
|
27
32
|
initial_backoff: DEFAULT_BACKOFF, max_retry_after: MAX_RETRY_AFTER,
|
|
28
|
-
retry_ambiguous: false, logger: nil
|
|
33
|
+
retry_ambiguous: false, logger: nil,
|
|
34
|
+
total_timeout: timeout, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
|
|
29
35
|
unless account_id.is_a?(String) && account_id.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
30
36
|
raise ConfigurationError, "account_id is required and must be a single account identifier"
|
|
31
37
|
end
|
|
32
38
|
unless api_token.is_a?(String) && !api_token.empty? && !api_token.match?(/\s/)
|
|
33
39
|
raise ConfigurationError, "api_token is required and must not contain whitespace"
|
|
34
40
|
end
|
|
41
|
+
[[:timeout, timeout], [:total_timeout, total_timeout]].each do |name, value|
|
|
42
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
43
|
+
raise ConfigurationError, "#{name} must be a finite positive number"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
unless max_response_bytes.is_a?(Integer) && max_response_bytes.positive?
|
|
47
|
+
raise ConfigurationError, "max_response_bytes must be a positive integer"
|
|
48
|
+
end
|
|
35
49
|
|
|
36
50
|
@account_id = account_id
|
|
37
51
|
@api_token = api_token
|
|
38
52
|
@base_url = Endpoint.parse(base_url).to_s.delete_suffix("/")
|
|
39
53
|
@retries = retries
|
|
40
54
|
@timeout = timeout
|
|
55
|
+
@total_timeout = total_timeout
|
|
56
|
+
@max_response_bytes = max_response_bytes
|
|
41
57
|
@initial_backoff = initial_backoff
|
|
42
58
|
@max_retry_after = max_retry_after
|
|
43
59
|
@logger = logger
|
|
@@ -143,9 +159,9 @@ module Cloudflare
|
|
|
143
159
|
do_request(method, uri, body)
|
|
144
160
|
rescue *RETRYABLE_NETWORK => e
|
|
145
161
|
unless PRE_SEND_NETWORK.any? { |type| e.is_a?(type) } || @retry_ambiguous
|
|
146
|
-
raise NetworkError.new("
|
|
162
|
+
raise NetworkError.new("provider response could not be read; delivery outcome is unknown; automatic retry disabled"), cause: nil
|
|
147
163
|
end
|
|
148
|
-
raise NetworkError.new(
|
|
164
|
+
raise NetworkError.new("provider request could not be completed; request outcome is unknown"), cause: nil if attempts > @retries
|
|
149
165
|
log_retry(attempts, e)
|
|
150
166
|
sleep(backoff); backoff *= 2
|
|
151
167
|
retry
|
|
@@ -194,13 +210,32 @@ module Cloudflare
|
|
|
194
210
|
req["User-Agent"] = "cloudflare-email-ruby/#{Cloudflare::Email::VERSION}"
|
|
195
211
|
req.body = JSON.generate(body) if body
|
|
196
212
|
|
|
197
|
-
|
|
198
|
-
|
|
213
|
+
raw = +"".b
|
|
214
|
+
response = nil
|
|
215
|
+
byte_limit = response_byte_limit(uri)
|
|
216
|
+
# Bound each network attempt, including trickling headers/body. Parsing,
|
|
217
|
+
# queue handlers, and database transactions are outside this deadline.
|
|
218
|
+
Timeout.timeout(@total_timeout) do
|
|
219
|
+
http.request(req) do |incoming|
|
|
220
|
+
response = incoming
|
|
221
|
+
incoming.read_body do |chunk|
|
|
222
|
+
# Net::HTTP yields decompressed bytes, so compressed expansion is
|
|
223
|
+
# subject to the same budget. Check before extending the buffer.
|
|
224
|
+
raise IOError, "provider response exceeds byte limit" if raw.bytesize + chunk.bytesize > byte_limit
|
|
225
|
+
raw << chunk
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
handle_response(response, raw)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def response_byte_limit(_uri)
|
|
233
|
+
@max_response_bytes
|
|
199
234
|
end
|
|
200
235
|
|
|
201
|
-
def handle_response(response)
|
|
236
|
+
def handle_response(response, raw = response.body)
|
|
202
237
|
status = response.code.to_i
|
|
203
|
-
body = parse_body(
|
|
238
|
+
body = parse_body(raw)
|
|
204
239
|
retry_after = response["Retry-After"]
|
|
205
240
|
|
|
206
241
|
# Stash Retry-After on the error response so retry logic can use it.
|
|
@@ -236,7 +271,7 @@ module Cloudflare
|
|
|
236
271
|
return {} if raw.nil? || raw.empty?
|
|
237
272
|
JSON.parse(raw)
|
|
238
273
|
rescue JSON::ParserError
|
|
239
|
-
{ "errors" => [{ "message" =>
|
|
274
|
+
{ "errors" => [{ "message" => "invalid JSON API response; request outcome is unknown" }] }
|
|
240
275
|
end
|
|
241
276
|
|
|
242
277
|
def extract_message(body)
|
|
@@ -25,6 +25,8 @@ module Cloudflare
|
|
|
25
25
|
base_url: settings[:base_url] || Cloudflare::Email::Client::DEFAULT_BASE_URL,
|
|
26
26
|
retries: settings.fetch(:retries, Cloudflare::Email::Client::DEFAULT_RETRIES),
|
|
27
27
|
timeout: settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT),
|
|
28
|
+
total_timeout: settings.fetch(:total_timeout, settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT)),
|
|
29
|
+
max_response_bytes: settings.fetch(:max_response_bytes, Cloudflare::Email::Client::DEFAULT_MAX_RESPONSE_BYTES),
|
|
28
30
|
initial_backoff: settings.fetch(:initial_backoff, Cloudflare::Email::Client::DEFAULT_BACKOFF),
|
|
29
31
|
max_retry_after: settings.fetch(:max_retry_after, Cloudflare::Email::Client::MAX_RETRY_AFTER),
|
|
30
32
|
retry_ambiguous: settings.fetch(:retry_ambiguous, false),
|
|
@@ -6,8 +6,8 @@ module Cloudflare
|
|
|
6
6
|
# `bin/rails cloudflare:email:deploy_worker` — uploads the Worker
|
|
7
7
|
# script + both secrets via the Cloudflare API. No wrangler required.
|
|
8
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
|
|
9
|
+
def self.call(script_path: nil, ingress_url: nil, delivery_mode: "durable", io: $stdout)
|
|
10
|
+
new(io: io, script_path: script_path, ingress_url: ingress_url, delivery_mode: delivery_mode).call
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
protected
|
|
@@ -28,7 +28,7 @@ module Cloudflare
|
|
|
28
28
|
)
|
|
29
29
|
|
|
30
30
|
say " Deploying Worker '#{deployer.script_name}'..."
|
|
31
|
-
deployer.deploy(script_path: path)
|
|
31
|
+
deployer.deploy(script_path: path, delivery_mode: opts[:delivery_mode])
|
|
32
32
|
say " ✓ Worker script deployed"
|
|
33
33
|
|
|
34
34
|
deployer.put_secret("INGRESS_SECRET", ingress_secret)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "rails/engine"
|
|
2
|
+
require "mailbox_kit/railtie"
|
|
2
3
|
require "cloudflare/email/dev_ingress_guard"
|
|
3
4
|
|
|
4
5
|
# Register the delivery method at engine load time (not inside an initializer)
|
|
@@ -18,20 +19,12 @@ module Cloudflare
|
|
|
18
19
|
app.middleware.insert_before 0, DevIngressGuard if Rails.env.development?
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
config.to_prepare do
|
|
22
|
-
if (defined?(Cloudflare::Email::Tenancy) && Cloudflare::Email::Tenancy.enabled?) ||
|
|
23
|
-
(defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?)
|
|
24
|
-
require "cloudflare/email/tenant_job_context"
|
|
25
|
-
Cloudflare::Email::TenantJobContext.install_framework_jobs!
|
|
26
|
-
end
|
|
27
|
-
if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled? && defined?(::ActionMailbox::Engine)
|
|
28
|
-
require "cloudflare/email/mailboxes/inbound_retention"
|
|
29
|
-
::ActionMailbox::InboundEmail.prepend(Cloudflare::Email::Mailboxes::InboundRetention) unless
|
|
30
|
-
::ActionMailbox::InboundEmail.ancestors.include?(Cloudflare::Email::Mailboxes::InboundRetention)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
22
|
config.before_initialize do
|
|
23
|
+
unless defined?(Cloudflare::Email::Management::Engine)
|
|
24
|
+
Rails.autoloaders.main.ignore(
|
|
25
|
+
File.expand_path("../../../app/controllers/cloudflare/email/management", __dir__),
|
|
26
|
+
)
|
|
27
|
+
end
|
|
35
28
|
unless defined?(::ActionMailbox::Engine)
|
|
36
29
|
Rails.autoloaders.main.ignore(
|
|
37
30
|
File.expand_path("../../../app/controllers/cloudflare/email/ingress_controller.rb", __dir__),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "mailbox_kit/address_syntax"
|
|
1
2
|
require "json"
|
|
2
3
|
require "cloudflare/email/signing"
|
|
3
4
|
|
|
@@ -8,19 +9,8 @@ module Cloudflare
|
|
|
8
9
|
module Envelope
|
|
9
10
|
METADATA_KEY = "cloudflare_email_envelope".freeze
|
|
10
11
|
MAX_ENCODED_BYTES = 1024
|
|
11
|
-
LOCAL_PART = /\A[A-Za-z0-9.!#$%&'*+\/=\?^_`{|}~-]+\z/
|
|
12
|
-
DOMAIN_LABEL = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
|
|
13
|
-
|
|
14
12
|
def self.valid_address?(address, allow_empty: false)
|
|
15
|
-
|
|
16
|
-
return true if allow_empty && address.empty?
|
|
17
|
-
return false if address.bytesize > 254
|
|
18
|
-
parts = address.split("@", -1)
|
|
19
|
-
return false unless parts.size == 2
|
|
20
|
-
local, domain = parts
|
|
21
|
-
local.bytesize <= 64 && LOCAL_PART.match?(local) &&
|
|
22
|
-
!local.start_with?(".") && !local.end_with?(".") && !local.include?("..") &&
|
|
23
|
-
domain.split(".", -1).all? { |label| DOMAIN_LABEL.match?(label) }
|
|
13
|
+
MailboxKit::AddressSyntax.valid_address?(address, allow_empty: allow_empty)
|
|
24
14
|
end
|
|
25
15
|
|
|
26
16
|
def self.valid?(value)
|
|
@@ -45,11 +35,11 @@ module Cloudflare
|
|
|
45
35
|
nil
|
|
46
36
|
end
|
|
47
37
|
|
|
48
|
-
# Only metadata written after v2 verification is trusted. MIME headers
|
|
38
|
+
# Only metadata written after v2/v3 verification is trusted. MIME headers
|
|
49
39
|
# (including X-CF-* headers) never participate in this lookup.
|
|
50
40
|
def self.for(inbound_email)
|
|
51
41
|
metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
|
|
52
|
-
return nil unless metadata.is_a?(Hash) && metadata["version"]
|
|
42
|
+
return nil unless metadata.is_a?(Hash) && [2, 3].include?(metadata["version"])
|
|
53
43
|
value = metadata.reject { |key, _| key == "version" }
|
|
54
44
|
valid?(value) ? value : nil
|
|
55
45
|
end
|
|
@@ -1,18 +1,11 @@
|
|
|
1
|
+
require "mailbox_kit/error"
|
|
2
|
+
|
|
1
3
|
module Cloudflare
|
|
2
4
|
module Email
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def initialize(message = nil, status: nil, response: nil)
|
|
7
|
-
super(message)
|
|
8
|
-
@status = status
|
|
9
|
-
@response = response
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
class ConfigurationError < Error; end
|
|
5
|
+
Error = MailboxKit::Error
|
|
6
|
+
ConfigurationError = MailboxKit::ConfigurationError
|
|
14
7
|
class AuthenticationError < Error; end
|
|
15
|
-
|
|
8
|
+
ValidationError = MailboxKit::ValidationError
|
|
16
9
|
class RateLimitError < Error; end
|
|
17
10
|
class ServerError < Error; end
|
|
18
11
|
class NetworkError < Error; end
|
|
@@ -9,9 +9,12 @@ module Cloudflare
|
|
|
9
9
|
# Handler/parse/ack failures propagate; unacknowledged leases expire and
|
|
10
10
|
# are redelivered according to the queue's retry/dead-letter policy.
|
|
11
11
|
class EventConsumer < Client
|
|
12
|
-
|
|
12
|
+
# Up to 100 queue messages, including encoded payloads and metadata.
|
|
13
|
+
DEFAULT_MAX_RESPONSE_BYTES = 32 * 1_048_576
|
|
14
|
+
|
|
15
|
+
def initialize(queue_id:, domains: nil, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, **options)
|
|
13
16
|
raise ConfigurationError, "queue_id is required" unless queue_id.to_s.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
14
|
-
super(**options.merge(retries: 0, retry_ambiguous: false))
|
|
17
|
+
super(**options.merge(retries: 0, retry_ambiguous: false, max_response_bytes: max_response_bytes))
|
|
15
18
|
@queue_id = queue_id
|
|
16
19
|
@domains = domains && Array(domains).map(&:downcase)
|
|
17
20
|
end
|
|
@@ -53,6 +56,12 @@ module Cloudflare
|
|
|
53
56
|
|
|
54
57
|
private
|
|
55
58
|
|
|
59
|
+
def response_byte_limit(uri)
|
|
60
|
+
# ACKs contain counts, never a queue batch. Keep their smaller budget
|
|
61
|
+
# even when a pull needs room for 100 encoded event payloads.
|
|
62
|
+
uri.path.end_with?("/messages/ack") ? [@max_response_bytes, Client::DEFAULT_MAX_RESPONSE_BYTES].min : @max_response_bytes
|
|
63
|
+
end
|
|
64
|
+
|
|
56
65
|
def queue_path(action)
|
|
57
66
|
"/accounts/#{account_id}/queues/#{@queue_id}/messages/#{action}"
|
|
58
67
|
end
|
|
@@ -79,7 +88,7 @@ module Cloudflare
|
|
|
79
88
|
end
|
|
80
89
|
DeliveryEvent.new(body)
|
|
81
90
|
rescue JSON::ParserError, ArgumentError, TypeError
|
|
82
|
-
raise ValidationError, "queue body must contain a valid JSON Email Sending event"
|
|
91
|
+
raise ValidationError, "queue body must contain a valid JSON Email Sending event", cause: nil
|
|
83
92
|
end
|
|
84
93
|
end
|
|
85
94
|
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "cloudflare/email/verification"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Shared bounded verification for custom Rack/Rails ingestion endpoints.
|
|
7
|
+
# This API deliberately performs no domain lookup or business processing.
|
|
8
|
+
module Ingress
|
|
9
|
+
DEFAULT_MAX_EMAIL_BYTES = 25 * 1024 * 1024
|
|
10
|
+
Result = Struct.new(:status, :message, :bytes, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
class VerifiedMessage
|
|
13
|
+
attr_reader :body, :envelope, :provider_metadata, :storage_metadata, :message_checksum
|
|
14
|
+
|
|
15
|
+
def initialize(body:, envelope:, provider_metadata:, version:)
|
|
16
|
+
@body = body.b.freeze
|
|
17
|
+
@envelope = Ingress.deep_freeze(envelope)
|
|
18
|
+
@provider_metadata = Ingress.deep_freeze(provider_metadata)
|
|
19
|
+
@storage_metadata = {Envelope::METADATA_KEY => envelope.merge("version" => version.to_i)}
|
|
20
|
+
if provider_metadata
|
|
21
|
+
@storage_metadata[ProviderMetadata::METADATA_KEY] = provider_metadata.merge("signature_version" => 3)
|
|
22
|
+
end
|
|
23
|
+
Ingress.deep_freeze(@storage_metadata)
|
|
24
|
+
identity = if version == "3"
|
|
25
|
+
"v3\0#{JSON.generate([envelope.fetch('from'), envelope.fetch('to'), ProviderMetadata.canonical(provider_metadata)])}\0".b
|
|
26
|
+
else
|
|
27
|
+
"v2\0#{envelope.fetch('to')}\0".b
|
|
28
|
+
end
|
|
29
|
+
@message_checksum = Digest::SHA256.hexdigest(identity + @body).freeze
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
private_class_method :new
|
|
33
|
+
|
|
34
|
+
# Call within Mailboxes.receive when using the optional registry so the
|
|
35
|
+
# framework records and memberships share the resolved tenant context.
|
|
36
|
+
# Custom endpoints may instead persist body/metadata in their own store.
|
|
37
|
+
def persist_action_mailbox!
|
|
38
|
+
result = persist_rails_record
|
|
39
|
+
result.record if result.created?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Verify once, resolve the envelope recipient before storage, then use
|
|
43
|
+
# the kit's ordinary Rails membership path. A duplicate can repair a
|
|
44
|
+
# missing membership without scheduling Rails processing again.
|
|
45
|
+
def receive_into_mailbox!
|
|
46
|
+
result = nil
|
|
47
|
+
Mailboxes.receive(recipient: envelope.fetch("to")) do
|
|
48
|
+
result = persist_rails_record
|
|
49
|
+
result.record
|
|
50
|
+
end
|
|
51
|
+
result.record if result.created?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def persist_rails_record
|
|
57
|
+
require "mailbox_kit/inbound_email"
|
|
58
|
+
MailboxKit::InboundEmail.persist(source: body, message_checksum: message_checksum,
|
|
59
|
+
message_id: stable_message_id, metadata: storage_metadata)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# ActionMailbox's fallback includes Socket.gethostname. A replay on a
|
|
63
|
+
# different Rails host must use the same database identity, while the
|
|
64
|
+
# original MIME stays untouched. Preserve parseable provider IDs.
|
|
65
|
+
def stable_message_id
|
|
66
|
+
parsed = begin
|
|
67
|
+
::Mail.from_source(body).message_id
|
|
68
|
+
rescue StandardError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
parsed || "#{message_checksum}@cloudflare-email.invalid"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.verify(secret:, headers:, body:, content_length: nil,
|
|
76
|
+
max_email_bytes: DEFAULT_MAX_EMAIL_BYTES, now: Time.now.to_i, window: Verification::DEFAULT_WINDOW)
|
|
77
|
+
unless max_email_bytes.is_a?(Integer) && max_email_bytes.positive?
|
|
78
|
+
raise ArgumentError, "max_email_bytes must be a positive integer"
|
|
79
|
+
end
|
|
80
|
+
values = %w[Timestamp Signature Signature-Version Envelope Metadata].to_h do |name|
|
|
81
|
+
key = "X-CF-Email-#{name}"
|
|
82
|
+
value = headers[key] || headers["HTTP_#{key.upcase.tr('-', '_')}"]
|
|
83
|
+
[name, value.is_a?(String) ? value.dup.freeze : value]
|
|
84
|
+
end
|
|
85
|
+
options = {secret: secret, timestamp: values["Timestamp"], signature: values["Signature"],
|
|
86
|
+
version: values["Signature-Version"], envelope: values["Envelope"], metadata: values["Metadata"],
|
|
87
|
+
now: now, window: window}
|
|
88
|
+
status = Verification.verify_headers(**options)
|
|
89
|
+
return Result.new(status: status).freeze unless status == :ok
|
|
90
|
+
if content_length
|
|
91
|
+
length = Integer(content_length.to_s, exception: false)
|
|
92
|
+
return Result.new(status: :bad_signature).freeze unless length && length >= 0
|
|
93
|
+
return Result.new(status: :too_large).freeze if length > max_email_bytes
|
|
94
|
+
end
|
|
95
|
+
raw = if body.is_a?(String)
|
|
96
|
+
return Result.new(status: :too_large).freeze if body.bytesize > max_email_bytes
|
|
97
|
+
body.dup.b
|
|
98
|
+
elsif body.respond_to?(:read)
|
|
99
|
+
buffer = +"".b
|
|
100
|
+
while buffer.bytesize <= max_email_bytes
|
|
101
|
+
chunk = body.read(max_email_bytes + 1 - buffer.bytesize)
|
|
102
|
+
break if chunk.nil? || chunk.empty?
|
|
103
|
+
buffer << chunk.b
|
|
104
|
+
end
|
|
105
|
+
buffer
|
|
106
|
+
else
|
|
107
|
+
raise ArgumentError, "body must be a String or readable IO"
|
|
108
|
+
end
|
|
109
|
+
return Result.new(status: :too_large).freeze if raw.bytesize > max_email_bytes
|
|
110
|
+
status = Verification.verify(body: raw, **options)
|
|
111
|
+
return Result.new(status: status, bytes: raw.bytesize).freeze unless status == :ok
|
|
112
|
+
message = VerifiedMessage.send(:new, body: raw, version: values["Signature-Version"],
|
|
113
|
+
envelope: Envelope.decode(values["Envelope"]), provider_metadata: ProviderMetadata.decode(values["Metadata"]))
|
|
114
|
+
Result.new(status: :ok, message: message, bytes: raw.bytesize).freeze
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.deep_freeze(value)
|
|
118
|
+
case value
|
|
119
|
+
when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
|
120
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
121
|
+
end
|
|
122
|
+
value.freeze
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -1,28 +1,23 @@
|
|
|
1
|
-
require "
|
|
1
|
+
require "mailbox_kit/mailboxes/configuration"
|
|
2
2
|
require "cloudflare/email/error"
|
|
3
3
|
|
|
4
4
|
module Cloudflare
|
|
5
5
|
module Email
|
|
6
|
+
Mailboxes = MailboxKit::Mailboxes unless const_defined?(:Mailboxes, false)
|
|
6
7
|
module Mailboxes
|
|
7
|
-
class Unavailable < Cloudflare::Email::Error; end
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
module CloudflareConfiguration
|
|
10
10
|
def configure(directory_base: ::ActiveRecord::Base, client_resolver: nil)
|
|
11
|
-
if const_defined?(:ReceivingDomain, false)
|
|
12
|
-
raise ConfigurationError, "configure mailboxes before loading mailbox models"
|
|
13
|
-
end
|
|
14
|
-
unless directory_base.is_a?(Class) && directory_base <= ::ActiveRecord::Base
|
|
15
|
-
raise ConfigurationError, "directory_base must be an ActiveRecord base class"
|
|
16
|
-
end
|
|
17
11
|
if client_resolver && !client_resolver.respond_to?(:call)
|
|
18
12
|
raise ConfigurationError, "client_resolver must be callable"
|
|
19
13
|
end
|
|
20
|
-
|
|
14
|
+
super(directory_base: directory_base)
|
|
21
15
|
@client_resolver = client_resolver
|
|
22
16
|
end
|
|
17
|
+
end
|
|
18
|
+
singleton_class.prepend(CloudflareConfiguration)
|
|
23
19
|
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
class << self
|
|
26
21
|
def delivery_handler = rails_setting(:outbox_delivery_handler)
|
|
27
22
|
def recipient_handler = rails_setting(:outbox_recipient_handler)
|
|
28
23
|
|
|
@@ -42,9 +37,6 @@ module Cloudflare
|
|
|
42
37
|
client
|
|
43
38
|
end
|
|
44
39
|
|
|
45
|
-
def enabled? = @enabled == true
|
|
46
|
-
def enable! = @enabled = true
|
|
47
|
-
|
|
48
40
|
private
|
|
49
41
|
|
|
50
42
|
def rails_setting(name)
|
|
@@ -1,14 +1 @@
|
|
|
1
|
-
|
|
2
|
-
module Email
|
|
3
|
-
module Mailboxes
|
|
4
|
-
# ActionMailbox normally deletes processed mail after its retention window.
|
|
5
|
-
# A mailbox membership owns the raw source until explicit application purge.
|
|
6
|
-
module InboundRetention
|
|
7
|
-
def incinerate
|
|
8
|
-
return if Mailboxes.enabled? && Message.where(inbound_email_id: id).exists?
|
|
9
|
-
super
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|
|
1
|
+
require "mailbox_kit/mailboxes/inbound_retention"
|