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
|
@@ -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,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"
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "time"
|
|
5
|
+
require "cloudflare/email/endpoint"
|
|
4
6
|
|
|
5
7
|
module Cloudflare
|
|
6
8
|
module Email
|
|
@@ -12,39 +14,50 @@ module Cloudflare
|
|
|
12
14
|
MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
|
|
13
15
|
|
|
14
16
|
RETRYABLE_NETWORK = [
|
|
15
|
-
Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
|
|
17
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
|
|
16
18
|
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
|
|
17
19
|
IOError
|
|
18
20
|
].freeze
|
|
21
|
+
PRE_SEND_NETWORK = [Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError].freeze
|
|
19
22
|
|
|
20
|
-
attr_reader :account_id, :base_url, :retries, :timeout
|
|
23
|
+
attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous
|
|
21
24
|
|
|
22
25
|
def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
|
|
23
26
|
retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
|
|
24
27
|
initial_backoff: DEFAULT_BACKOFF, max_retry_after: MAX_RETRY_AFTER,
|
|
25
|
-
logger: nil)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
retry_ambiguous: false, logger: nil)
|
|
29
|
+
unless account_id.is_a?(String) && account_id.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
30
|
+
raise ConfigurationError, "account_id is required and must be a single account identifier"
|
|
31
|
+
end
|
|
32
|
+
unless api_token.is_a?(String) && !api_token.empty? && !api_token.match?(/\s/)
|
|
33
|
+
raise ConfigurationError, "api_token is required and must not contain whitespace"
|
|
34
|
+
end
|
|
28
35
|
|
|
29
36
|
@account_id = account_id
|
|
30
37
|
@api_token = api_token
|
|
31
|
-
@base_url = base_url
|
|
38
|
+
@base_url = Endpoint.parse(base_url).to_s.delete_suffix("/")
|
|
32
39
|
@retries = retries
|
|
33
40
|
@timeout = timeout
|
|
34
41
|
@initial_backoff = initial_backoff
|
|
35
42
|
@max_retry_after = max_retry_after
|
|
36
43
|
@logger = logger
|
|
44
|
+
@retry_ambiguous = retry_ambiguous
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class.name} account_id=#{@account_id.inspect} api_token=[REDACTED]>"
|
|
37
49
|
end
|
|
38
50
|
|
|
39
|
-
def send(from:,
|
|
51
|
+
def send(from:, subject:, to: nil, text: nil, html: nil, cc: nil, bcc: nil,
|
|
40
52
|
reply_to: nil, headers: nil, attachments: nil)
|
|
41
53
|
raise ValidationError, "must provide :text or :html" if text.nil? && html.nil?
|
|
54
|
+
raise ValidationError, "must provide a recipient in :to, :cc, or :bcc" if [to, cc, bcc].all? { |v| wrap(v).empty? }
|
|
42
55
|
|
|
43
56
|
body = {
|
|
44
57
|
from: normalize_address(from),
|
|
45
|
-
to: wrap(to).map { |addr| normalize_address(addr) },
|
|
46
58
|
subject: subject,
|
|
47
59
|
}
|
|
60
|
+
body[:to] = wrap(to).map { |addr| normalize_address(addr) } if to
|
|
48
61
|
body[:text] = text if text
|
|
49
62
|
body[:html] = html if html
|
|
50
63
|
body[:cc] = wrap(cc).map { |a| normalize_address(a) } if cc
|
|
@@ -104,6 +117,10 @@ module Cloudflare
|
|
|
104
117
|
response = request(:post, path, body)
|
|
105
118
|
payload[:status] = response.status
|
|
106
119
|
payload[:message_id] = response.message_id
|
|
120
|
+
payload[:delivered] = response.delivered
|
|
121
|
+
payload[:queued] = response.queued
|
|
122
|
+
payload[:permanent_bounces] = response.permanent_bounces
|
|
123
|
+
payload[:suppressed_recipients] = response.suppressed_recipients
|
|
107
124
|
response
|
|
108
125
|
end
|
|
109
126
|
end
|
|
@@ -125,6 +142,9 @@ module Cloudflare
|
|
|
125
142
|
attempts += 1
|
|
126
143
|
do_request(method, uri, body)
|
|
127
144
|
rescue *RETRYABLE_NETWORK => e
|
|
145
|
+
unless PRE_SEND_NETWORK.any? { |type| e.is_a?(type) } || @retry_ambiguous
|
|
146
|
+
raise NetworkError.new("#{e.message}; delivery outcome is unknown; automatic retry disabled")
|
|
147
|
+
end
|
|
128
148
|
raise NetworkError.new(e.message) if attempts > @retries
|
|
129
149
|
log_retry(attempts, e)
|
|
130
150
|
sleep(backoff); backoff *= 2
|
|
@@ -135,7 +155,7 @@ module Cloudflare
|
|
|
135
155
|
sleep(retry_after_from(e, backoff)); backoff *= 2
|
|
136
156
|
retry
|
|
137
157
|
rescue ServerError => e
|
|
138
|
-
raise
|
|
158
|
+
raise unless @retry_ambiguous && attempts <= @retries
|
|
139
159
|
log_retry(attempts, e)
|
|
140
160
|
sleep(backoff); backoff *= 2
|
|
141
161
|
retry
|
|
@@ -145,7 +165,15 @@ module Cloudflare
|
|
|
145
165
|
def retry_after_from(error, fallback_backoff)
|
|
146
166
|
header = error.response.is_a?(Hash) ? error.response["retry_after"] : nil
|
|
147
167
|
value = header || fallback_backoff
|
|
148
|
-
seconds = value.
|
|
168
|
+
seconds = if value.to_s.match?(/\A\d+(?:\.\d+)?\z/)
|
|
169
|
+
value.to_f
|
|
170
|
+
else
|
|
171
|
+
begin
|
|
172
|
+
Time.httpdate(value.to_s) - Time.now
|
|
173
|
+
rescue ArgumentError
|
|
174
|
+
fallback_backoff
|
|
175
|
+
end
|
|
176
|
+
end
|
|
149
177
|
return fallback_backoff if seconds <= 0
|
|
150
178
|
[seconds, @max_retry_after].min
|
|
151
179
|
end
|
|
@@ -155,6 +183,8 @@ module Cloudflare
|
|
|
155
183
|
http.use_ssl = (uri.scheme == "https")
|
|
156
184
|
http.open_timeout = @timeout
|
|
157
185
|
http.read_timeout = @timeout
|
|
186
|
+
http.write_timeout = @timeout
|
|
187
|
+
http.max_retries = 0
|
|
158
188
|
|
|
159
189
|
req_class = { post: Net::HTTP::Post, get: Net::HTTP::Get }.fetch(method)
|
|
160
190
|
req = req_class.new(uri.request_uri)
|
|
@@ -178,6 +208,15 @@ module Cloudflare
|
|
|
178
208
|
|
|
179
209
|
case status
|
|
180
210
|
when 200..299
|
|
211
|
+
if body.is_a?(Hash) && body.key?("success") && body["success"] != true
|
|
212
|
+
raise Error.new(extract_message(body), status: status, response: body)
|
|
213
|
+
end
|
|
214
|
+
unless body.is_a?(Hash) && body["result"].is_a?(Hash)
|
|
215
|
+
raise Error.new("invalid successful API response; request outcome is unknown", status: status, response: body)
|
|
216
|
+
end
|
|
217
|
+
if body["errors"].is_a?(Array) && body["errors"].any?
|
|
218
|
+
raise Error.new(extract_message(body), status: status, response: body)
|
|
219
|
+
end
|
|
181
220
|
Response.new(body, status: status)
|
|
182
221
|
when 400, 422
|
|
183
222
|
raise ValidationError.new(extract_message(body), status: status, response: body)
|
|
@@ -209,7 +248,8 @@ module Cloudflare
|
|
|
209
248
|
|
|
210
249
|
def log_retry(attempt, error)
|
|
211
250
|
return unless @logger
|
|
212
|
-
|
|
251
|
+
# Provider messages can echo message content or other sensitive data.
|
|
252
|
+
@logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}")
|
|
213
253
|
end
|
|
214
254
|
end
|
|
215
255
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
require "cloudflare/email/task_base"
|
|
2
|
+
require "cloudflare/email/event_consumer"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
class ConsumeEventsTask < TaskBase
|
|
7
|
+
def run
|
|
8
|
+
handler = opts[:handler]
|
|
9
|
+
raise ConfigurationError, "configure config.x.cloudflare_email.event_handler with a callable" unless handler.respond_to?(:call)
|
|
10
|
+
|
|
11
|
+
consumer = EventConsumer.new(
|
|
12
|
+
account_id: account_id,
|
|
13
|
+
api_token: require_value!(credential(:queues_token), "cloudflare.queues_token / CLOUDFLARE_QUEUES_TOKEN"),
|
|
14
|
+
queue_id: require_value!(credential(:event_queue_id), "cloudflare.event_queue_id / CLOUDFLARE_EVENT_QUEUE_ID"),
|
|
15
|
+
domains: opts[:domains],
|
|
16
|
+
)
|
|
17
|
+
count = consumer.poll(batch_size: opts.fetch(:batch_size, 5), &handler.method(:call))
|
|
18
|
+
say " Processed and acknowledged #{count} delivery event(s)."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# An outbound Email Sending event, delivered through Cloudflare Queues.
|
|
7
|
+
# Treat event_id as an idempotency key; events may arrive more than once
|
|
8
|
+
# or out of order. This is distinct from inbound ActionMailbox messages.
|
|
9
|
+
class DeliveryEvent
|
|
10
|
+
TYPES = %w[delivered deferred bounced failed rejected complained].freeze
|
|
11
|
+
attr_reader :raw
|
|
12
|
+
|
|
13
|
+
def initialize(raw)
|
|
14
|
+
@raw = raw
|
|
15
|
+
unless raw.is_a?(Hash) && raw["type"].is_a?(String) &&
|
|
16
|
+
raw["type"].match?(/\Acf\.email\.sending\.message\.[a-z][a-z0-9_]*\z/) &&
|
|
17
|
+
raw["payload"].is_a?(Hash) && raw["source"].is_a?(Hash) &&
|
|
18
|
+
raw["source"]["type"] == "email.sending" && raw["metadata"].is_a?(Hash) &&
|
|
19
|
+
raw["metadata"]["eventSchemaVersion"] == 1 &&
|
|
20
|
+
[event_id, message_id, account_id, domain, recipient].all? { |value| valid_identifier?(value) } &&
|
|
21
|
+
[true, false].include?(payload["terminal"]) &&
|
|
22
|
+
%w[delivery bounce complaint rejection failure].all? { |key| !payload.key?(key) || payload[key].is_a?(Hash) }
|
|
23
|
+
raise ValidationError, "invalid Email Sending event or unsupported schema version"
|
|
24
|
+
end
|
|
25
|
+
unless occurred_at.is_a?(String) && valid_identifier?(occurred_at)
|
|
26
|
+
raise ValidationError, "delivery event timestamp must be ISO8601"
|
|
27
|
+
end
|
|
28
|
+
Time.iso8601(occurred_at)
|
|
29
|
+
rescue ArgumentError
|
|
30
|
+
raise ValidationError, "delivery event timestamp must be ISO8601"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def type = raw["type"]
|
|
34
|
+
def status = type.delete_prefix("cf.email.sending.message.")
|
|
35
|
+
def known? = TYPES.include?(status)
|
|
36
|
+
def payload = raw["payload"]
|
|
37
|
+
def event_id = payload["eventId"]
|
|
38
|
+
def message_id = payload["messageId"]
|
|
39
|
+
def recipient = payload["recipient"]
|
|
40
|
+
def sender = payload["sender"]
|
|
41
|
+
def terminal? = payload["terminal"] == true
|
|
42
|
+
def domain = raw["source"]["domain"]
|
|
43
|
+
def account_id = raw["metadata"]["accountId"]
|
|
44
|
+
def occurred_at = raw["metadata"]["eventTimestamp"]
|
|
45
|
+
def delivery = payload["delivery"] || {}
|
|
46
|
+
def bounce = payload["bounce"] || {}
|
|
47
|
+
def complaint = payload["complaint"] || {}
|
|
48
|
+
def rejection = payload["rejection"] || {}
|
|
49
|
+
def failure = payload["failure"] || {}
|
|
50
|
+
|
|
51
|
+
# Use only after matching account, provider message ID, and recipient.
|
|
52
|
+
# Equal timestamps keep the existing state; unknown future types remain
|
|
53
|
+
# available in receipts but must not replace a recognized delivery state.
|
|
54
|
+
def supersedes?(occurred_at:, terminal:)
|
|
55
|
+
return false unless known?
|
|
56
|
+
return false if terminal && !terminal?
|
|
57
|
+
|
|
58
|
+
incoming = Time.iso8601(self.occurred_at.to_s)
|
|
59
|
+
previous = occurred_at.is_a?(Time) ? occurred_at : Time.iso8601(occurred_at.to_s) unless occurred_at.nil?
|
|
60
|
+
previous.nil? || incoming > previous
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def valid_identifier?(value)
|
|
66
|
+
value.is_a?(String) && !value.strip.empty? && !value.match?(/[[:cntrl:]]/)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -25,6 +25,9 @@ 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
|
+
initial_backoff: settings.fetch(:initial_backoff, Cloudflare::Email::Client::DEFAULT_BACKOFF),
|
|
29
|
+
max_retry_after: settings.fetch(:max_retry_after, Cloudflare::Email::Client::MAX_RETRY_AFTER),
|
|
30
|
+
retry_ambiguous: settings.fetch(:retry_ambiguous, false),
|
|
28
31
|
logger: settings[:logger],
|
|
29
32
|
)
|
|
30
33
|
|
|
@@ -20,6 +20,9 @@ module Cloudflare
|
|
|
20
20
|
path = script_path
|
|
21
21
|
raise "Worker script not found at #{path} — re-run `bin/rails g cloudflare:email:install`" unless File.exist?(path)
|
|
22
22
|
|
|
23
|
+
# Validate before uploading code or rotating any deployed secrets.
|
|
24
|
+
Endpoint.parse(url) unless url.to_s.empty?
|
|
25
|
+
|
|
23
26
|
deployer = Cloudflare::Email::WorkerDeployer.new(
|
|
24
27
|
account_id: account_id, api_token: management_token,
|
|
25
28
|
)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
# cloudflared forces this origin Host. Restrict tunnel traffic before Rails
|
|
4
|
+
# routing, debug middleware and normal host authorization execute.
|
|
5
|
+
class DevIngressGuard
|
|
6
|
+
HOST = "cloudflare-email-ingress.localhost".freeze
|
|
7
|
+
PATH = "/rails/action_mailbox/cloudflare/inbound_emails".freeze
|
|
8
|
+
RESPONSE_HEADER = "x-cloudflare-email-ingress-only".freeze
|
|
9
|
+
|
|
10
|
+
def initialize(app)
|
|
11
|
+
@app = app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(env)
|
|
15
|
+
return @app.call(env) unless env["HTTP_HOST"].to_s.downcase.split(":", 2).first == HOST
|
|
16
|
+
unless env["REQUEST_METHOD"] == "POST" && env["PATH_INFO"] == PATH
|
|
17
|
+
return [404, { "content-type" => "text/plain", "content-length" => "0", RESPONSE_HEADER => "1" }, []]
|
|
18
|
+
end
|
|
19
|
+
forwarded = env.dup
|
|
20
|
+
forwarded["HTTP_HOST"] = "localhost"
|
|
21
|
+
forwarded.delete("HTTP_X_FORWARDED_HOST")
|
|
22
|
+
forwarded.delete("HTTP_FORWARDED")
|
|
23
|
+
@app.call(forwarded)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require "cloudflare/email/worker_deployer"
|
|
2
|
+
require "tempfile"
|
|
3
|
+
require "cloudflare/email/dev_ingress_guard"
|
|
2
4
|
|
|
3
5
|
module Cloudflare
|
|
4
6
|
module Email
|
|
@@ -13,6 +15,7 @@ module Cloudflare
|
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
def initialize(port:, io: $stdout)
|
|
18
|
+
raise ArgumentError, "port must be between 1 and 65535" unless port.is_a?(Integer) && (1..65_535).cover?(port)
|
|
16
19
|
@port = port
|
|
17
20
|
@io = io
|
|
18
21
|
@tunnel_pid = nil
|
|
@@ -51,22 +54,45 @@ module Cloudflare
|
|
|
51
54
|
private
|
|
52
55
|
|
|
53
56
|
def check_prerequisites
|
|
57
|
+
unless defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
58
|
+
raise "cloudflare:email:dev only runs in development; use RAILS_ENV=development"
|
|
59
|
+
end
|
|
60
|
+
unless Rails.application.config.respond_to?(:action_mailbox) &&
|
|
61
|
+
Rails.application.config.action_mailbox.ingress == :cloudflare
|
|
62
|
+
raise "Set config.action_mailbox.ingress = :cloudflare in config/environments/development.rb"
|
|
63
|
+
end
|
|
64
|
+
|
|
54
65
|
unless system("command -v cloudflared >/dev/null 2>&1")
|
|
55
66
|
raise "cloudflared not found in PATH — install from https://developers.cloudflare.com/cloudflared/"
|
|
56
67
|
end
|
|
57
68
|
require "cloudflare/email/credentials"
|
|
58
|
-
if Cloudflare::Email::Credentials.account_id.empty? ||
|
|
59
|
-
Cloudflare::Email::Credentials.management_token.empty?
|
|
69
|
+
if Cloudflare::Email::Credentials.account_id.to_s.empty? ||
|
|
70
|
+
Cloudflare::Email::Credentials.management_token.to_s.empty?
|
|
60
71
|
raise "Missing cloudflare.account_id or cloudflare.api_token in credentials " \
|
|
61
72
|
"(or CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN env vars)"
|
|
62
73
|
end
|
|
74
|
+
verify_ingress_guard!
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def verify_ingress_guard!
|
|
78
|
+
http = Net::HTTP.new("127.0.0.1", @port, nil)
|
|
79
|
+
http.open_timeout = http.read_timeout = 2
|
|
80
|
+
request = Net::HTTP::Get.new("/")
|
|
81
|
+
request["Host"] = DevIngressGuard::HOST
|
|
82
|
+
response = http.request(request)
|
|
83
|
+
unless response.code == "404" && response[DevIngressGuard::RESPONSE_HEADER] == "1"
|
|
84
|
+
raise "Restart the development Rails server with the ingress-only middleware before opening a tunnel"
|
|
85
|
+
end
|
|
86
|
+
rescue SystemCallError, IOError, Timeout::Error
|
|
87
|
+
raise "Start the development Rails server with the ingress-only middleware before opening a tunnel"
|
|
63
88
|
end
|
|
64
89
|
|
|
65
90
|
def start_tunnel
|
|
66
91
|
@io.puts " Starting cloudflared tunnel on :#{@port}..."
|
|
67
|
-
@tunnel_log =
|
|
92
|
+
@tunnel_log = Tempfile.new(["cloudflare-email-dev-tunnel", ".log"])
|
|
68
93
|
@tunnel_pid = spawn(
|
|
69
94
|
"cloudflared", "tunnel", "--url", "http://127.0.0.1:#{@port}",
|
|
95
|
+
"--http-host-header", DevIngressGuard::HOST,
|
|
70
96
|
out: @tunnel_log, err: @tunnel_log,
|
|
71
97
|
)
|
|
72
98
|
end
|
|
@@ -75,7 +101,7 @@ module Cloudflare
|
|
|
75
101
|
deadline = Time.now + 30
|
|
76
102
|
while Time.now < deadline
|
|
77
103
|
sleep 0.5
|
|
78
|
-
log = File.read(
|
|
104
|
+
log = File.read(@tunnel_log.path) rescue ""
|
|
79
105
|
if (match = log.match(%r{https://[a-z0-9\-]+\.trycloudflare\.com}))
|
|
80
106
|
return match[0]
|
|
81
107
|
end
|
|
@@ -89,7 +115,7 @@ module Cloudflare
|
|
|
89
115
|
Process.wait(@tunnel_pid) rescue nil
|
|
90
116
|
@tunnel_pid = nil
|
|
91
117
|
end
|
|
92
|
-
@tunnel_log&.close
|
|
118
|
+
@tunnel_log&.close!
|
|
93
119
|
end
|
|
94
120
|
|
|
95
121
|
end
|