cloudflare-email 0.1.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 +149 -1
- data/README.md +287 -385
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
- 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 +181 -0
- data/docs/architecture.md +105 -0
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +186 -0
- data/docs/features.md +138 -0
- data/docs/getting-started.md +308 -0
- data/docs/mailboxes.md +363 -0
- data/docs/management-engine.md +190 -0
- data/docs/outbox.md +175 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +104 -0
- data/docs/upgrading-0.2.md +101 -0
- data/docs/upgrading-0.3.md +55 -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-custom-ingress.md +49 -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/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +60 -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/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +93 -18
- 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 +5 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
- 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 +33 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +95 -0
- data/lib/cloudflare/email/ingress.rb +115 -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 +165 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +372 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- 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/message_id.rb +12 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -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_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- 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 +38 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +41 -8
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +53 -37
- 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/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -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/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/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 +42 -4
- 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 +231 -16
- data/templates/worker/docs/durable-inbound.md +149 -0
- 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 +397 -25
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +183 -13
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +63 -4
- metadata +154 -7
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/base"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class OutboundDelivery < Base
|
|
7
|
+
self.table_name = "cloudflare_email_outbound_deliveries"
|
|
8
|
+
has_many :outbound_recipients, class_name: "Cloudflare::Email::ActiveRecord::OutboundRecipient", dependent: :restrict_with_exception
|
|
9
|
+
has_many :outbound_reconciliations, class_name: "Cloudflare::Email::ActiveRecord::OutboundReconciliation", dependent: :restrict_with_exception
|
|
10
|
+
attr_readonly :account_id, :operation_key, :from_address, :recipients_json, :mime_message, :snapshot_digest
|
|
11
|
+
|
|
12
|
+
def recipients
|
|
13
|
+
JSON.parse(recipients_json)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def response
|
|
17
|
+
Response.new(JSON.parse(response_json)) if response_json
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/base"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class OutboundRecipient < Base
|
|
7
|
+
self.table_name = "cloudflare_email_outbound_recipients"
|
|
8
|
+
belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
|
|
9
|
+
attr_readonly :outbound_delivery_id, :recipient
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/base"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module ActiveRecord
|
|
6
|
+
class OutboundReconciliation < Base
|
|
7
|
+
self.table_name = "cloudflare_email_outbound_reconciliations"
|
|
8
|
+
belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
|
|
9
|
+
def readonly?
|
|
10
|
+
persisted?
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "cloudflare-email"
|
|
4
|
+
require "cloudflare/email/active_record/outbound_delivery"
|
|
5
|
+
require "cloudflare/email/active_record/outbound_recipient"
|
|
6
|
+
require "cloudflare/email/active_record/outbound_reconciliation"
|
|
7
|
+
|
|
8
|
+
module Cloudflare
|
|
9
|
+
module Email
|
|
10
|
+
module ActiveRecord
|
|
11
|
+
# Durable, single-attempt boundary. A retry job may revisit an accepted
|
|
12
|
+
# operation, but uncertainty always requires an explicit operator decision.
|
|
13
|
+
class Outbox
|
|
14
|
+
class Error < Cloudflare::Email::Error; end
|
|
15
|
+
class InvalidTransition < Error; end
|
|
16
|
+
class SnapshotConflict < Error; end
|
|
17
|
+
|
|
18
|
+
class << self
|
|
19
|
+
def acceptance_state(states)
|
|
20
|
+
aggregate(states)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def normalize_recipient(value)
|
|
24
|
+
local, separator, domain = value.to_s.rpartition("@")
|
|
25
|
+
separator.empty? ? value.to_s : "#{local}@#{domain.downcase}"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def prepare(account_id:, operation_key:, from:, recipients:, mime_message:)
|
|
29
|
+
[account_id, operation_key, from].each do |value|
|
|
30
|
+
raise ArgumentError, "account, operation key and sender must be nonempty strings" unless value.is_a?(String) && !value.strip.empty?
|
|
31
|
+
end
|
|
32
|
+
unless recipients.is_a?(Array) && recipients.any? && recipients.all? { |v| v.is_a?(String) && !v.strip.empty? }
|
|
33
|
+
raise ArgumentError, "recipients must be a nonempty array of addresses"
|
|
34
|
+
end
|
|
35
|
+
raise ArgumentError, "mime_message must be a nonempty String" unless mime_message.is_a?(String) && !mime_message.empty?
|
|
36
|
+
addresses = recipients.map { |address| normalize_recipient(address) }.uniq
|
|
37
|
+
json = JSON.generate(addresses)
|
|
38
|
+
digest = Digest::SHA256.hexdigest(JSON.generate([account_id, from, addresses]) + "\0" + mime_message.b)
|
|
39
|
+
OutboundDelivery.transaction do
|
|
40
|
+
delivery = OutboundDelivery.create_or_find_by!(account_id: account_id, operation_key: operation_key) do |row|
|
|
41
|
+
row.assign_attributes(from_address: from, recipients_json: json, mime_message: mime_message.b,
|
|
42
|
+
snapshot_digest: digest, state: "prepared")
|
|
43
|
+
end
|
|
44
|
+
raise SnapshotConflict, "operation key already has a different immutable message" unless delivery.snapshot_digest == digest
|
|
45
|
+
addresses.each { |address| delivery.outbound_recipients.create_or_find_by!(recipient: address) }
|
|
46
|
+
delivery
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def deliver(delivery, client: nil)
|
|
51
|
+
persisted!(delivery)
|
|
52
|
+
outside_transaction!
|
|
53
|
+
delivery.reload
|
|
54
|
+
return delivery if %w[accepted partial].include?(delivery.state)
|
|
55
|
+
client ||= Client.new(account_id: Credentials.account_id, api_token: Credentials.api_token)
|
|
56
|
+
unless client.account_id.to_s == delivery.account_id && client.respond_to?(:retry_ambiguous) && client.retry_ambiguous == false
|
|
57
|
+
raise ConfigurationError, "outbox client must match the account and disable ambiguous retries"
|
|
58
|
+
end
|
|
59
|
+
now = Time.now.utc
|
|
60
|
+
claimed = OutboundDelivery.where(id: delivery.id, state: "prepared").update_all(state: "sending", request_started_at: now, updated_at: now)
|
|
61
|
+
unless claimed == 1
|
|
62
|
+
delivery.reload
|
|
63
|
+
return delivery if %w[accepted partial].include?(delivery.state)
|
|
64
|
+
raise InvalidTransition, "cannot send an operation in #{delivery.state}; uncertainty requires reconciliation"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Everything after this durable claim can leave an ambiguous outcome.
|
|
68
|
+
begin
|
|
69
|
+
response = client.send_raw(from: delivery.from_address, recipients: delivery.recipients, mime_message: delivery.mime_message)
|
|
70
|
+
rescue StandardError => error
|
|
71
|
+
record_failure(delivery, error)
|
|
72
|
+
raise
|
|
73
|
+
end
|
|
74
|
+
begin
|
|
75
|
+
outcomes, provider_message_id = response_outcomes(response, delivery.recipients)
|
|
76
|
+
delivery.with_lock do
|
|
77
|
+
raise InvalidTransition, "send claim changed during delivery" unless delivery.state == "sending"
|
|
78
|
+
outcomes.each do |address, state|
|
|
79
|
+
delivery.outbound_recipients.find_by!(recipient: address).update!(state: state, acceptance_state: state, occurred_at: nil,
|
|
80
|
+
terminal: %w[delivered bounced suppressed].include?(state))
|
|
81
|
+
end
|
|
82
|
+
delivery.update!(state: aggregate(outcomes.values), provider_message_id: provider_message_id,
|
|
83
|
+
response_json: JSON.generate(response.to_h), completed_at: Time.now.utc, error_class: nil)
|
|
84
|
+
end
|
|
85
|
+
rescue StandardError => error
|
|
86
|
+
# Even a local validation/persistence error here follows provider
|
|
87
|
+
# acceptance. Never convert it into permission to resend.
|
|
88
|
+
mark_unknown(delivery.id, error)
|
|
89
|
+
raise
|
|
90
|
+
end
|
|
91
|
+
delivery.reload
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def reconcile(delivery, outcome:, actor:, reason:, evidence:, provider_message_id: nil, recipients: nil, confirm_sender_stopped: false)
|
|
95
|
+
persisted!(delivery)
|
|
96
|
+
raise ArgumentError, "outcome must be accepted or not_sent" unless %w[accepted not_sent].include?(outcome.to_s)
|
|
97
|
+
[actor, reason, evidence].each do |value|
|
|
98
|
+
raise ArgumentError, "actor, reason and evidence are required strings" unless value.is_a?(String) && !value.strip.empty?
|
|
99
|
+
end
|
|
100
|
+
provider_message_id = validated_message_id(provider_message_id)
|
|
101
|
+
if outcome.to_s == "accepted" && provider_message_id.nil?
|
|
102
|
+
raise ArgumentError, "accepted reconciliation requires a provider message ID"
|
|
103
|
+
end
|
|
104
|
+
completed = false
|
|
105
|
+
delivery.with_lock(requires_new: true) do
|
|
106
|
+
allowed = %w[unknown partial].include?(delivery.state) || (delivery.state == "sending" && confirm_sender_stopped == true &&
|
|
107
|
+
delivery.request_started_at && delivery.request_started_at <= Time.now.utc - 900)
|
|
108
|
+
raise InvalidTransition, "only uncertain operations may be reconciled; a sending process must be confirmed stopped and at least 15 minutes old" unless allowed
|
|
109
|
+
rows = delivery.outbound_recipients.to_a
|
|
110
|
+
unresolved = rows.select { |row| %w[unknown prepared sending].include?(row.acceptance_state) }.map(&:recipient)
|
|
111
|
+
selected = recipients || unresolved
|
|
112
|
+
selected = selected.map { |address| normalize_recipient(address) } if selected.is_a?(Array)
|
|
113
|
+
unless selected.is_a?(Array) && selected.any? && (selected - unresolved).empty? && selected.uniq == selected
|
|
114
|
+
raise ArgumentError, "recipients must be unique unresolved snapshot recipients"
|
|
115
|
+
end
|
|
116
|
+
existing_id = validated_message_id(delivery.provider_message_id.presence)
|
|
117
|
+
if provider_message_id && existing_id && provider_message_id != existing_id
|
|
118
|
+
raise ArgumentError, "provider message ID conflicts with the existing operation"
|
|
119
|
+
end
|
|
120
|
+
delivery.outbound_reconciliations.create!(actor: actor, reason: reason, evidence: evidence,
|
|
121
|
+
outcome: outcome.to_s, provider_message_id: provider_message_id, recipients_json: JSON.generate(selected))
|
|
122
|
+
states = rows.map do |recipient|
|
|
123
|
+
unless selected.include?(recipient.recipient)
|
|
124
|
+
recipient.update!(state: "unknown", acceptance_state: "unknown") if %w[prepared sending].include?(recipient.acceptance_state)
|
|
125
|
+
next recipient.acceptance_state
|
|
126
|
+
end
|
|
127
|
+
state = outcome.to_s == "not_sent" ? "not_sent" : "accepted"
|
|
128
|
+
recipient.update!(state: state, acceptance_state: state, occurred_at: nil, terminal: state == "not_sent")
|
|
129
|
+
state
|
|
130
|
+
end
|
|
131
|
+
delivery.update!(state: states.all? { |state| state == "not_sent" } ? "confirmed_not_sent" : aggregate(states),
|
|
132
|
+
provider_message_id: provider_message_id || existing_id, completed_at: Time.now.utc)
|
|
133
|
+
yield delivery if block_given?
|
|
134
|
+
completed = true
|
|
135
|
+
end
|
|
136
|
+
raise InvalidTransition, "reconciliation rolled back instead of completing" unless completed
|
|
137
|
+
delivery.reload
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def persisted!(delivery)
|
|
143
|
+
raise ArgumentError, "expected a persisted OutboundDelivery" unless delivery.is_a?(OutboundDelivery) && delivery.persisted?
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def outside_transaction!
|
|
147
|
+
raise ArgumentError, "outbox network delivery must run outside an existing database transaction" if OutboundDelivery.connection.transaction_open?
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def record_failure(delivery, error)
|
|
151
|
+
# Only an explicit provider 4xx rejection proves nonacceptance.
|
|
152
|
+
rejected = error.is_a?(Cloudflare::Email::Error) && [400, 401, 403, 422, 429].include?(error.status)
|
|
153
|
+
OutboundDelivery.transaction do
|
|
154
|
+
count = OutboundDelivery.where(id: delivery.id, state: "sending").update_all(state: rejected ? "rejected" : "unknown",
|
|
155
|
+
error_class: error.class.name, completed_at: Time.now.utc, updated_at: Time.now.utc)
|
|
156
|
+
OutboundRecipient.where(outbound_delivery_id: delivery.id).update_all(state: rejected ? "rejected" : "unknown", acceptance_state: rejected ? "rejected" : "unknown") if count == 1
|
|
157
|
+
end
|
|
158
|
+
rescue StandardError
|
|
159
|
+
# A database outage leaves the already committed sending claim.
|
|
160
|
+
# It is deliberately just as non-retryable as unknown.
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def mark_unknown(id, error)
|
|
165
|
+
OutboundDelivery.where(id: id, state: "sending").update_all(state: "unknown", error_class: error.class.name, updated_at: Time.now.utc)
|
|
166
|
+
rescue StandardError
|
|
167
|
+
nil
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def response_outcomes(response, addresses)
|
|
171
|
+
raise ValidationError, "invalid provider response" unless response.is_a?(Response) && response.success?
|
|
172
|
+
outcomes = addresses.to_h { |address| [address, "unknown"] }
|
|
173
|
+
result = response.result
|
|
174
|
+
ids = [validated_message_id(result["message_id"])]
|
|
175
|
+
groups = { "delivered" => "delivered", "queued" => "queued", "permanent_bounces" => "bounced", "suppressed_recipients" => "suppressed" }
|
|
176
|
+
seen = {}
|
|
177
|
+
groups.each do |key, state|
|
|
178
|
+
entries = result.fetch(key, [])
|
|
179
|
+
raise ValidationError, "invalid recipient outcomes" unless entries.is_a?(Array)
|
|
180
|
+
entries.each do |entry|
|
|
181
|
+
ids << validated_message_id(entry["message_id"]) if entry.is_a?(Hash)
|
|
182
|
+
address = entry.is_a?(Hash) ? (entry["to"] || entry["email"] || entry["recipient"] || entry["address"]) : entry
|
|
183
|
+
address = normalize_recipient(address)
|
|
184
|
+
raise ValidationError, "unknown or conflicting provider recipient" unless addresses.include?(address) && !seen[address]
|
|
185
|
+
seen[address] = true
|
|
186
|
+
outcomes[address] = state
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
raise ValidationError, "multiple provider message IDs cannot be correlated to one operation" if ids.compact.uniq.size > 1
|
|
190
|
+
if seen.empty? && ids.compact.any?
|
|
191
|
+
outcomes.transform_values! { "accepted" }
|
|
192
|
+
end
|
|
193
|
+
[outcomes, ids.compact.first]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def validated_message_id(value)
|
|
197
|
+
return nil if value.nil?
|
|
198
|
+
unless value.is_a?(String)
|
|
199
|
+
raise ValidationError, "provider message ID must be a nonempty string"
|
|
200
|
+
end
|
|
201
|
+
normalized = MessageId.normalize(value)
|
|
202
|
+
if normalized.empty? || normalized.match?(/[\s<>]/)
|
|
203
|
+
raise ValidationError, "provider message ID must not contain whitespace or brackets"
|
|
204
|
+
end
|
|
205
|
+
normalized
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def aggregate(states)
|
|
209
|
+
accepted = states.count { |state| %w[accepted delivered queued].include?(state) }
|
|
210
|
+
return "accepted" if accepted == states.size
|
|
211
|
+
return "partial" if accepted.positive?
|
|
212
|
+
return "rejected" if states.all? { |state| %w[bounced suppressed rejected not_sent].include?(state) }
|
|
213
|
+
"unknown"
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module OutboxNotifications
|
|
5
|
+
def prepare(**options)
|
|
6
|
+
notify_outbox("prepare", account_id: options[:account_id], operation_key: options[:operation_key]) { super }
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def deliver(delivery, **options)
|
|
10
|
+
notify_outbox("send", account_id: delivery.account_id, operation_key: delivery.operation_key) { super }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def reconcile(delivery, **options, &block)
|
|
14
|
+
notify_outbox("reconcile", account_id: delivery.account_id, operation_key: delivery.operation_key) { super }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def notify_outbox(action, **payload)
|
|
20
|
+
return yield unless defined?(::ActiveSupport::Notifications)
|
|
21
|
+
|
|
22
|
+
::ActiveSupport::Notifications.instrument("cloudflare_email.outbox_#{action}", payload) do |details|
|
|
23
|
+
result = yield
|
|
24
|
+
details[:delivery_id] = result.id
|
|
25
|
+
details[:state] = result.state
|
|
26
|
+
result
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
Outbox.singleton_class.prepend(OutboxNotifications)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -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,5 @@
|
|
|
1
|
+
# Optional durable Rails/ActiveRecord integration. Jobs are a separate opt-in.
|
|
2
|
+
require "cloudflare/email/active_record/outbox"
|
|
3
|
+
require "cloudflare/email/active_record/mail_snapshot"
|
|
4
|
+
require "cloudflare/email/active_record/delivery_events"
|
|
5
|
+
require "cloudflare/email/active_record/outbox_notifications"
|
|
@@ -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
|