cloudflare-email 0.1.0 → 0.2.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 +73 -1
- data/README.md +265 -388
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +62 -6
- data/docs/activerecord-tenanted.md +135 -0
- data/docs/architecture.md +95 -0
- data/docs/delivery-events.md +177 -0
- data/docs/features.md +107 -0
- data/docs/getting-started.md +298 -0
- data/docs/mailboxes.md +299 -0
- data/docs/outbox.md +175 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +103 -0
- data/docs/upgrading-0.2.md +96 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +48 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/client.rb +51 -11
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +3 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -0
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +28 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +86 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +148 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +288 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- data/lib/cloudflare/email/message_id.rb +12 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +30 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +5 -3
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +48 -33
- data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +54 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +41 -4
- data/templates/worker/README.md +61 -11
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +83 -4
- data/templates/worker/test/index.test.ts +117 -5
- data/templates/worker/wrangler.toml +13 -4
- metadata +107 -6
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
|
@@ -64,7 +64,7 @@ module Cloudflare
|
|
|
64
64
|
if api_token.to_s.empty?
|
|
65
65
|
record("credentials.cloudflare.api_token", FAIL, "missing — run bin/rails credentials:edit")
|
|
66
66
|
else
|
|
67
|
-
record("credentials.cloudflare.api_token", OK, "
|
|
67
|
+
record("credentials.cloudflare.api_token", OK, "set (redacted)")
|
|
68
68
|
end
|
|
69
69
|
end
|
|
70
70
|
|
|
@@ -89,47 +89,20 @@ module Cloudflare
|
|
|
89
89
|
if response[:ok]
|
|
90
90
|
record("Account accessible", OK, response[:body].dig("result", "name") || account_id)
|
|
91
91
|
elsif response[:status] == 403
|
|
92
|
-
#
|
|
93
|
-
# that
|
|
94
|
-
record("Account accessible",
|
|
92
|
+
# Send-scoped tokens may lack account read permission. A 403 cannot
|
|
93
|
+
# establish that this token can send for the configured account.
|
|
94
|
+
record("Account accessible", SKIP, "403 — account access unverified; token may lack account read permission")
|
|
95
95
|
else
|
|
96
96
|
record("Account accessible", FAIL, extract_error(response))
|
|
97
97
|
end
|
|
98
98
|
end
|
|
99
99
|
|
|
100
100
|
def check_sending_domains
|
|
101
|
-
|
|
102
|
-
account_id = credential(:account_id)
|
|
103
|
-
return record("Sending domains", SKIP, "missing credentials") if token.to_s.empty? || account_id.to_s.empty?
|
|
104
|
-
|
|
105
|
-
response = request("GET", "/accounts/#{account_id}/email/sending/domains", token: token)
|
|
106
|
-
if response[:status] == 403 || response[:status] == 404
|
|
107
|
-
record("Sending domains", SKIP, "send-scoped token can't list domains (check the dashboard instead)")
|
|
108
|
-
return
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
unless response[:ok]
|
|
112
|
-
record("Sending domains", WARN, "could not list: #{extract_error(response)}")
|
|
113
|
-
return
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
domains = Array(response[:body]["result"])
|
|
117
|
-
if domains.empty?
|
|
118
|
-
record("Sending domains", WARN, "no sending domains set up — add one in the dashboard")
|
|
119
|
-
return
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
verified = domains.select { |d| d["verified"] == true || d["status"] == "verified" }
|
|
123
|
-
if verified.any?
|
|
124
|
-
names = verified.map { |d| d["name"] || d["domain"] }.compact.join(", ")
|
|
125
|
-
record("Sending domains", OK, "#{verified.size} verified (#{names})")
|
|
126
|
-
else
|
|
127
|
-
names = domains.map { |d| d["name"] || d["domain"] }.compact.join(", ")
|
|
128
|
-
record("Sending domains", WARN, "#{domains.size} configured but none verified (#{names})")
|
|
129
|
-
end
|
|
101
|
+
record("Sending domains", SKIP, "verify your sender domain in Email Sending in the Cloudflare dashboard")
|
|
130
102
|
end
|
|
131
103
|
|
|
132
104
|
def check_ingress_secret
|
|
105
|
+
return record("Ingress secret", SKIP, "Cloudflare inbound routing not selected") unless inbound_enabled?
|
|
133
106
|
require "cloudflare/email/credentials"
|
|
134
107
|
secret = Cloudflare::Email::Credentials.ingress_secret
|
|
135
108
|
|
|
@@ -143,6 +116,7 @@ module Cloudflare
|
|
|
143
116
|
end
|
|
144
117
|
|
|
145
118
|
def check_token_split
|
|
119
|
+
return record("Token split", SKIP, "send-only setup; management token optional") unless inbound_enabled?
|
|
146
120
|
require "cloudflare/email/credentials"
|
|
147
121
|
if Cloudflare::Email::Credentials.split_tokens?
|
|
148
122
|
record("Token split", OK, "separate management_token set (good security posture)")
|
|
@@ -160,12 +134,16 @@ module Cloudflare
|
|
|
160
134
|
when :cloudflare
|
|
161
135
|
record("ActionMailbox ingress", OK, ":cloudflare")
|
|
162
136
|
when nil
|
|
163
|
-
record("ActionMailbox ingress",
|
|
137
|
+
record("ActionMailbox ingress", SKIP, "inbound routing not configured")
|
|
164
138
|
else
|
|
165
|
-
record("ActionMailbox ingress",
|
|
139
|
+
record("ActionMailbox ingress", SKIP, "#{ActionMailbox.ingress.inspect} — Cloudflare inbound routing not selected")
|
|
166
140
|
end
|
|
167
141
|
end
|
|
168
142
|
|
|
143
|
+
def inbound_enabled?
|
|
144
|
+
defined?(ActionMailbox) && ActionMailbox.respond_to?(:ingress) && ActionMailbox.ingress == :cloudflare
|
|
145
|
+
end
|
|
146
|
+
|
|
169
147
|
def check_delivery_method_registered
|
|
170
148
|
return record("Delivery method :cloudflare", SKIP, "ActionMailer not loaded") unless defined?(ActionMailer)
|
|
171
149
|
|
|
@@ -188,9 +166,9 @@ module Cloudflare
|
|
|
188
166
|
warn_count = @results.count { |r| r[:status] == WARN }
|
|
189
167
|
|
|
190
168
|
if fail_count.zero? && warn_count.zero?
|
|
191
|
-
@io.puts "
|
|
169
|
+
@io.puts " Completed checks passed. Review skipped checks; live delivery is not verified."
|
|
192
170
|
elsif fail_count.zero?
|
|
193
|
-
@io.puts " #{warn_count} warning(s).
|
|
171
|
+
@io.puts " #{warn_count} warning(s). Review warnings and skipped checks before relying on delivery."
|
|
194
172
|
else
|
|
195
173
|
@io.puts " #{fail_count} failure(s), #{warn_count} warning(s). Fix failures before sending."
|
|
196
174
|
end
|
|
@@ -228,7 +206,7 @@ module Cloudflare
|
|
|
228
206
|
|
|
229
207
|
response = http.request(req)
|
|
230
208
|
body = JSON.parse(response.body) rescue {}
|
|
231
|
-
{ ok: response.code.to_i.between?(200, 299), status: response.code.to_i, body: body }
|
|
209
|
+
{ ok: response.code.to_i.between?(200, 299) && body.is_a?(Hash) && body["success"] != false, status: response.code.to_i, body: body }
|
|
232
210
|
rescue StandardError => e
|
|
233
211
|
{ ok: false, status: 0, body: { "errors" => [{ "message" => e.message }] } }
|
|
234
212
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require "cloudflare/email/error"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Endpoints are trusted operator configuration, never message-derived URLs.
|
|
7
|
+
# Plain HTTP is only suitable for literal loopback development fixtures.
|
|
8
|
+
module Endpoint
|
|
9
|
+
LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1].freeze
|
|
10
|
+
|
|
11
|
+
def self.parse(value, allow_loopback: true)
|
|
12
|
+
uri = URI.parse(value.to_s)
|
|
13
|
+
secure = uri.is_a?(URI::HTTPS)
|
|
14
|
+
local = allow_loopback && uri.is_a?(URI::HTTP) && LOOPBACK_HOSTS.include?(uri.hostname.to_s.downcase)
|
|
15
|
+
unless (secure || local) && !uri.hostname.to_s.empty? && !uri.userinfo && !uri.query && !uri.fragment
|
|
16
|
+
raise ConfigurationError, "endpoint must use HTTPS (HTTP only on literal loopback), without credentials, query or fragment"
|
|
17
|
+
end
|
|
18
|
+
uri
|
|
19
|
+
rescue URI::InvalidURIError
|
|
20
|
+
raise ConfigurationError, "endpoint must be a valid HTTPS URL"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "rails/engine"
|
|
2
|
+
require "cloudflare/email/dev_ingress_guard"
|
|
2
3
|
|
|
3
4
|
# Register the delivery method at engine load time (not inside an initializer)
|
|
4
5
|
# so the `cloudflare_settings=` accessor exists before Rails' own
|
|
@@ -13,7 +14,34 @@ module Cloudflare
|
|
|
13
14
|
class Engine < ::Rails::Engine
|
|
14
15
|
isolate_namespace Cloudflare::Email
|
|
15
16
|
|
|
17
|
+
initializer "cloudflare-email.development_ingress_guard" do |app|
|
|
18
|
+
app.middleware.insert_before 0, DevIngressGuard if Rails.env.development?
|
|
19
|
+
end
|
|
20
|
+
|
|
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
|
+
config.before_initialize do
|
|
35
|
+
unless defined?(::ActionMailbox::Engine)
|
|
36
|
+
Rails.autoloaders.main.ignore(
|
|
37
|
+
File.expand_path("../../../app/controllers/cloudflare/email/ingress_controller.rb", __dir__),
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
16
42
|
initializer "cloudflare-email.routes" do |app|
|
|
43
|
+
next unless defined?(::ActionMailbox::Engine)
|
|
44
|
+
|
|
17
45
|
app.routes.append do
|
|
18
46
|
post "/rails/action_mailbox/cloudflare/inbound_emails",
|
|
19
47
|
to: "cloudflare/email/ingress#create",
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "cloudflare/email/signing"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Authenticated SMTP routing metadata, kept separate from sender-supplied MIME.
|
|
7
|
+
# v2 encodes exact {"from": string, "to": string} JSON as unpadded base64url.
|
|
8
|
+
module Envelope
|
|
9
|
+
METADATA_KEY = "cloudflare_email_envelope".freeze
|
|
10
|
+
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
|
+
def self.valid_address?(address, allow_empty: false)
|
|
15
|
+
return false unless address.is_a?(String) && address.ascii_only?
|
|
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) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.valid?(value)
|
|
27
|
+
value.is_a?(Hash) && value.size == 2 && value.key?("from") && value.key?("to") &&
|
|
28
|
+
valid_address?(value["from"], allow_empty: true) && valid_address?(value["to"])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.encode(from:, to:)
|
|
32
|
+
value = { "from" => from, "to" => to }
|
|
33
|
+
raise ArgumentError, "invalid SMTP envelope" unless valid?(value)
|
|
34
|
+
Signing.base64url_encode(JSON.generate(value))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.decode(encoded)
|
|
38
|
+
return nil unless encoded.is_a?(String) && encoded.bytesize.between?(1, MAX_ENCODED_BYTES)
|
|
39
|
+
return nil unless /\A[A-Za-z0-9_-]+\z/.match?(encoded)
|
|
40
|
+
decoded = Signing.base64url_decode(encoded)
|
|
41
|
+
return nil unless Signing.base64url_encode(decoded) == encoded
|
|
42
|
+
value = JSON.parse(decoded)
|
|
43
|
+
valid?(value) ? value : nil
|
|
44
|
+
rescue JSON::ParserError, ArgumentError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Only metadata written after v2 verification is trusted. MIME headers
|
|
49
|
+
# (including X-CF-* headers) never participate in this lookup.
|
|
50
|
+
def self.for(inbound_email)
|
|
51
|
+
metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
|
|
52
|
+
return nil unless metadata.is_a?(Hash) && metadata["version"] == 2
|
|
53
|
+
value = metadata.reject { |key, _| key == "version" }
|
|
54
|
+
valid?(value) ? value : nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "cloudflare/email/client"
|
|
3
|
+
require "cloudflare/email/delivery_event"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
# Short-poll a dedicated Email Sending event queue from Ruby/Rails.
|
|
8
|
+
# Each message is acknowledged only after the caller's block succeeds.
|
|
9
|
+
# Handler/parse/ack failures propagate; unacknowledged leases expire and
|
|
10
|
+
# are redelivered according to the queue's retry/dead-letter policy.
|
|
11
|
+
class EventConsumer < Client
|
|
12
|
+
def initialize(queue_id:, domains: nil, **options)
|
|
13
|
+
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))
|
|
15
|
+
@queue_id = queue_id
|
|
16
|
+
@domains = domains && Array(domains).map(&:downcase)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def poll(batch_size: 5, visibility_timeout_ms: 300_000)
|
|
20
|
+
raise ArgumentError, "a delivery event handler block is required" unless block_given?
|
|
21
|
+
unless batch_size.is_a?(Integer) && (1..100).cover?(batch_size)
|
|
22
|
+
raise ArgumentError, "batch_size must be between 1 and 100"
|
|
23
|
+
end
|
|
24
|
+
unless visibility_timeout_ms.is_a?(Integer) && (1_000..43_200_000).cover?(visibility_timeout_ms)
|
|
25
|
+
raise ArgumentError, "visibility_timeout_ms must be between 1000 and 43200000"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
response = request(:post, queue_path("pull"), {
|
|
29
|
+
batch_size: batch_size, visibility_timeout_ms: visibility_timeout_ms,
|
|
30
|
+
})
|
|
31
|
+
messages = response.result["messages"]
|
|
32
|
+
raise Error, "invalid queue pull response: messages must be an array" unless messages.is_a?(Array)
|
|
33
|
+
|
|
34
|
+
messages.each do |message|
|
|
35
|
+
unless message.is_a?(Hash) && message["lease_id"].is_a?(String) && !message["lease_id"].empty?
|
|
36
|
+
raise ValidationError, "queue message is missing its lease_id"
|
|
37
|
+
end
|
|
38
|
+
event = decode_event(message)
|
|
39
|
+
unless event.account_id == account_id && (!@domains || @domains.include?(event.domain.to_s.downcase))
|
|
40
|
+
raise ValidationError, "Email Sending event does not match configured account/domain"
|
|
41
|
+
end
|
|
42
|
+
instrument("cloudflare_email.delivery_event", event_id: event.event_id,
|
|
43
|
+
message_id: event.message_id, status: event.status) do
|
|
44
|
+
yield event
|
|
45
|
+
end
|
|
46
|
+
ack = request(:post, queue_path("ack"), { acks: [{ lease_id: message["lease_id"] }], retries: [] })
|
|
47
|
+
unless ack.result["ackCount"] == 1
|
|
48
|
+
raise Error.new("queue did not acknowledge the message; it may be redelivered", response: ack.raw, status: ack.status)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
messages.length
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def queue_path(action)
|
|
57
|
+
"/accounts/#{account_id}/queues/#{@queue_id}/messages/#{action}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def decode_event(message)
|
|
61
|
+
body = message["body"]
|
|
62
|
+
metadata = message["metadata"]
|
|
63
|
+
content_type = metadata.is_a?(Hash) ? metadata["CF-Content-Type"] : nil
|
|
64
|
+
case content_type
|
|
65
|
+
when "json"
|
|
66
|
+
# Email Sending subscriptions return plain JSON strings from HTTP
|
|
67
|
+
# pull. Also accept the base64 JSON transport used by other producers.
|
|
68
|
+
begin
|
|
69
|
+
body = JSON.parse(body)
|
|
70
|
+
rescue JSON::ParserError
|
|
71
|
+
body = JSON.parse(Base64.strict_decode64(body))
|
|
72
|
+
end
|
|
73
|
+
when "bytes"
|
|
74
|
+
body = JSON.parse(Base64.strict_decode64(body))
|
|
75
|
+
when "text"
|
|
76
|
+
body = JSON.parse(body)
|
|
77
|
+
else
|
|
78
|
+
raise ValidationError, "unsupported queue content type; use json, bytes, or text"
|
|
79
|
+
end
|
|
80
|
+
DeliveryEvent.new(body)
|
|
81
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
82
|
+
raise ValidationError, "queue body must contain a valid JSON Email Sending event"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "cloudflare/email/error"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Mailboxes
|
|
7
|
+
class Unavailable < Cloudflare::Email::Error; end
|
|
8
|
+
|
|
9
|
+
class << self
|
|
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
|
+
if client_resolver && !client_resolver.respond_to?(:call)
|
|
18
|
+
raise ConfigurationError, "client_resolver must be callable"
|
|
19
|
+
end
|
|
20
|
+
@directory_base = directory_base
|
|
21
|
+
@client_resolver = client_resolver
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def directory_base = @directory_base || ::ActiveRecord::Base
|
|
25
|
+
|
|
26
|
+
def delivery_handler = rails_setting(:outbox_delivery_handler)
|
|
27
|
+
def recipient_handler = rails_setting(:outbox_recipient_handler)
|
|
28
|
+
|
|
29
|
+
def client_for(tenant_key, account_id)
|
|
30
|
+
client = if @client_resolver
|
|
31
|
+
@client_resolver.call(tenant_key, account_id)
|
|
32
|
+
else
|
|
33
|
+
require "cloudflare/email/credentials"
|
|
34
|
+
unless Credentials.account_id == account_id
|
|
35
|
+
raise ConfigurationError, "configure a tenant/account client_resolver for this sending account"
|
|
36
|
+
end
|
|
37
|
+
Client.new(account_id: account_id, api_token: Credentials.api_token, retry_ambiguous: false)
|
|
38
|
+
end
|
|
39
|
+
unless client.account_id == account_id && client.retry_ambiguous == false
|
|
40
|
+
raise ConfigurationError, "mailbox client must match account and disable ambiguous retries"
|
|
41
|
+
end
|
|
42
|
+
client
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enabled? = @enabled == true
|
|
46
|
+
def enable! = @enabled = true
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def rails_setting(name)
|
|
51
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
52
|
+
::Rails.application.config.x.cloudflare_email.public_send(name)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/delivery_events"
|
|
2
|
+
require "cloudflare/email/mailboxes/shared_event_receipt"
|
|
3
|
+
require "cloudflare/email/mailboxes/provider_correlation"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
module Mailboxes
|
|
8
|
+
# Queue ACK follows shared intake. Projection is separately replayable:
|
|
9
|
+
# a crash between tenant commit and shared completion simply replays the
|
|
10
|
+
# tenant's idempotent receipt. No distributed transaction is assumed.
|
|
11
|
+
class Events
|
|
12
|
+
class << self
|
|
13
|
+
def record(event)
|
|
14
|
+
outside_shared_transaction!
|
|
15
|
+
event = DeliveryEvent.new(event.raw)
|
|
16
|
+
receipt = SharedEventReceipt.create_or_find_by!(account_id: event.account_id, event_id: event.event_id) do |row|
|
|
17
|
+
row.message_id = MessageId.normalize(event.message_id)
|
|
18
|
+
row.recipient = normalize_recipient(event.recipient)
|
|
19
|
+
row.payload_json = JSON.generate(event.raw)
|
|
20
|
+
row.state = "pending"
|
|
21
|
+
end
|
|
22
|
+
raise ValidationError, "event ID already recorded with a different payload" unless receipt.event.raw == event.raw
|
|
23
|
+
receipt
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Call after provider acceptance has committed in the tenant database.
|
|
27
|
+
# Duplicate registrations are harmless; collisions remain visible and
|
|
28
|
+
# deliberately prevent routing rather than selecting an arbitrary tenant.
|
|
29
|
+
def register(delivery, tenant_key: Tenancy.require_context!)
|
|
30
|
+
assert_context!(tenant_key)
|
|
31
|
+
outside_shared_transaction!
|
|
32
|
+
raise ArgumentError, "registration must follow the tenant commit" if delivery.class.connection.transaction_open?
|
|
33
|
+
delivery.reload
|
|
34
|
+
unless delivery.persisted? && %w[accepted partial].include?(delivery.state) && !delivery.provider_message_id.to_s.empty?
|
|
35
|
+
raise ArgumentError, "expected a committed accepted delivery with a provider message ID"
|
|
36
|
+
end
|
|
37
|
+
unless OutboundMessage.where(tenant_key: tenant_key, outbound_delivery_id: delivery.id).exists?
|
|
38
|
+
raise ArgumentError, "delivery does not belong to a mailbox in the current tenant"
|
|
39
|
+
end
|
|
40
|
+
delivery.outbound_recipients.map do |recipient|
|
|
41
|
+
ProviderCorrelation.create_or_find_by!(account_id: delivery.account_id,
|
|
42
|
+
message_id: MessageId.normalize(delivery.provider_message_id), recipient: normalize_recipient(recipient.recipient),
|
|
43
|
+
tenant_key: tenant_key.to_s, outbound_delivery_id: delivery.id)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply(receipt, &on_change)
|
|
48
|
+
outside_shared_transaction!
|
|
49
|
+
raise ArgumentError, "expected a persisted shared receipt" unless receipt.is_a?(SharedEventReceipt) && receipt.persisted?
|
|
50
|
+
receipt.reload
|
|
51
|
+
return receipt if receipt.state == "applied"
|
|
52
|
+
|
|
53
|
+
candidates = ProviderCorrelation.where(account_id: receipt.account_id,
|
|
54
|
+
message_id: receipt.message_id, recipient: receipt.recipient).limit(2).to_a
|
|
55
|
+
outcome = :unmatched
|
|
56
|
+
if candidates.length == 1
|
|
57
|
+
correlation = candidates.first
|
|
58
|
+
outcome = Tenancy.with(correlation.tenant_key) do
|
|
59
|
+
project(receipt.event, correlation, &on_change)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
# Never overwrite another worker's completed projection.
|
|
63
|
+
SharedEventReceipt.where(id: receipt.id, state: %w[pending unmatched]).update_all(
|
|
64
|
+
state: outcome.to_s, applied_at: outcome == :applied ? Time.now.utc : nil, updated_at: Time.now.utc)
|
|
65
|
+
receipt.reload
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A bounded page, not an unbounded find_each scan. Use the last scanned
|
|
69
|
+
# ID as after_id for the next page, and restart at zero on the next pass.
|
|
70
|
+
def replay(limit: 100, after_id: 0, account_id: nil, &on_change)
|
|
71
|
+
raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
|
|
72
|
+
raise ArgumentError, "after_id must be nonnegative" unless after_id.is_a?(Integer) && after_id >= 0
|
|
73
|
+
scope = SharedEventReceipt.where(state: %w[pending unmatched]).where("id > ?", after_id)
|
|
74
|
+
scope = scope.where(account_id: account_id) if account_id
|
|
75
|
+
scope.order(:id).limit(limit).to_a.each { |receipt| apply(receipt, &on_change) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def outside_shared_transaction!
|
|
81
|
+
if SharedEventReceipt.connection.transaction_open?
|
|
82
|
+
raise ArgumentError, "shared event processing must run outside an existing database transaction"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_recipient(value)
|
|
87
|
+
ActiveRecord::Outbox.normalize_recipient(value)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assert_context!(key)
|
|
91
|
+
raise ConfigurationError, "delivery belongs to another tenant" unless Tenancy.require_context! == key
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def project(event, correlation, &on_change)
|
|
95
|
+
assert_context!(correlation.tenant_key)
|
|
96
|
+
return :unmatched unless OutboundMessage.where(tenant_key: correlation.tenant_key,
|
|
97
|
+
outbound_delivery_id: correlation.outbound_delivery_id).exists?
|
|
98
|
+
delivery = ActiveRecord::OutboundDelivery.find_by(id: correlation.outbound_delivery_id,
|
|
99
|
+
account_id: event.account_id, provider_message_id: MessageId.normalize(event.message_id), state: %w[accepted partial])
|
|
100
|
+
return :unmatched unless delivery && delivery.outbound_recipients.exists?(recipient: normalize_recipient(event.recipient))
|
|
101
|
+
return :unmatched unless ReceivingDomain.active.where(tenant_key: correlation.tenant_key,
|
|
102
|
+
account_id: event.account_id, domain: delivery.from_address.rpartition("@").last.downcase).exists?
|
|
103
|
+
# DeliveryEvents also refuses collisions within the tenant. Its
|
|
104
|
+
# transactional receipt protects callback writes against redelivery.
|
|
105
|
+
receipt = ActiveRecord::DeliveryEvents.record(event, &on_change)
|
|
106
|
+
receipt.state == "applied" ? :applied : :unmatched
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Cloudflare
|
|
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
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "active_job"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module Mailboxes
|
|
6
|
+
class SendJob < ::ActiveJob::Base
|
|
7
|
+
queue_as :mailers
|
|
8
|
+
|
|
9
|
+
def perform(tenant_key, mailbox_id, operation_key)
|
|
10
|
+
Mailboxes.for_tenant(tenant_key) { |session| session.deliver(mailbox_id, operation_key: operation_key) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class RecoverJob < ::ActiveJob::Base
|
|
15
|
+
queue_as :mailers
|
|
16
|
+
|
|
17
|
+
def perform(tenant_key, after_id = 0)
|
|
18
|
+
cursor = Mailboxes.for_tenant(tenant_key) { |session| session.recover(after_id: after_id) }
|
|
19
|
+
self.class.perform_later(tenant_key, cursor) if cursor
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ReplayEventsJob < ::ActiveJob::Base
|
|
24
|
+
queue_as :mailers
|
|
25
|
+
|
|
26
|
+
def perform(after_id = 0)
|
|
27
|
+
handler = Mailboxes.recipient_handler
|
|
28
|
+
page = Events.replay(after_id: after_id, &(handler.method(:call) if handler.respond_to?(:call)))
|
|
29
|
+
self.class.perform_later(page.last.id) if page.length == 100
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|