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
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
require "net/http"
|
|
2
2
|
require "json"
|
|
3
3
|
require "uri"
|
|
4
|
+
require "time"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "cloudflare/email/endpoint"
|
|
4
8
|
|
|
5
9
|
module Cloudflare
|
|
6
10
|
module Email
|
|
@@ -9,42 +13,67 @@ module Cloudflare
|
|
|
9
13
|
DEFAULT_RETRIES = 3
|
|
10
14
|
DEFAULT_TIMEOUT = 30
|
|
11
15
|
DEFAULT_BACKOFF = 0.5
|
|
16
|
+
DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
|
|
12
17
|
MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
|
|
13
18
|
|
|
14
19
|
RETRYABLE_NETWORK = [
|
|
15
|
-
Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
|
|
20
|
+
Net::OpenTimeout, Net::ReadTimeout, Net::WriteTimeout, Errno::ECONNRESET,
|
|
16
21
|
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
|
|
17
|
-
IOError
|
|
22
|
+
IOError, Timeout::Error, Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError,
|
|
23
|
+
OpenSSL::SSL::SSLError, Zlib::Error
|
|
18
24
|
].freeze
|
|
25
|
+
PRE_SEND_NETWORK = [Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, SocketError].freeze
|
|
19
26
|
|
|
20
|
-
attr_reader :account_id, :base_url, :retries, :timeout
|
|
27
|
+
attr_reader :account_id, :base_url, :retries, :timeout, :retry_ambiguous,
|
|
28
|
+
:total_timeout, :max_response_bytes
|
|
21
29
|
|
|
22
30
|
def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
|
|
23
31
|
retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
|
|
24
32
|
initial_backoff: DEFAULT_BACKOFF, max_retry_after: MAX_RETRY_AFTER,
|
|
25
|
-
logger: nil
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
retry_ambiguous: false, logger: nil,
|
|
34
|
+
total_timeout: timeout, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES)
|
|
35
|
+
unless account_id.is_a?(String) && account_id.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
36
|
+
raise ConfigurationError, "account_id is required and must be a single account identifier"
|
|
37
|
+
end
|
|
38
|
+
unless api_token.is_a?(String) && !api_token.empty? && !api_token.match?(/\s/)
|
|
39
|
+
raise ConfigurationError, "api_token is required and must not contain whitespace"
|
|
40
|
+
end
|
|
41
|
+
[[:timeout, timeout], [:total_timeout, total_timeout]].each do |name, value|
|
|
42
|
+
unless value.is_a?(Numeric) && value.real? && value.finite? && value.positive?
|
|
43
|
+
raise ConfigurationError, "#{name} must be a finite positive number"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
unless max_response_bytes.is_a?(Integer) && max_response_bytes.positive?
|
|
47
|
+
raise ConfigurationError, "max_response_bytes must be a positive integer"
|
|
48
|
+
end
|
|
28
49
|
|
|
29
50
|
@account_id = account_id
|
|
30
51
|
@api_token = api_token
|
|
31
|
-
@base_url = base_url
|
|
52
|
+
@base_url = Endpoint.parse(base_url).to_s.delete_suffix("/")
|
|
32
53
|
@retries = retries
|
|
33
54
|
@timeout = timeout
|
|
55
|
+
@total_timeout = total_timeout
|
|
56
|
+
@max_response_bytes = max_response_bytes
|
|
34
57
|
@initial_backoff = initial_backoff
|
|
35
58
|
@max_retry_after = max_retry_after
|
|
36
59
|
@logger = logger
|
|
60
|
+
@retry_ambiguous = retry_ambiguous
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def inspect
|
|
64
|
+
"#<#{self.class.name} account_id=#{@account_id.inspect} api_token=[REDACTED]>"
|
|
37
65
|
end
|
|
38
66
|
|
|
39
|
-
def send(from:,
|
|
67
|
+
def send(from:, subject:, to: nil, text: nil, html: nil, cc: nil, bcc: nil,
|
|
40
68
|
reply_to: nil, headers: nil, attachments: nil)
|
|
41
69
|
raise ValidationError, "must provide :text or :html" if text.nil? && html.nil?
|
|
70
|
+
raise ValidationError, "must provide a recipient in :to, :cc, or :bcc" if [to, cc, bcc].all? { |v| wrap(v).empty? }
|
|
42
71
|
|
|
43
72
|
body = {
|
|
44
73
|
from: normalize_address(from),
|
|
45
|
-
to: wrap(to).map { |addr| normalize_address(addr) },
|
|
46
74
|
subject: subject,
|
|
47
75
|
}
|
|
76
|
+
body[:to] = wrap(to).map { |addr| normalize_address(addr) } if to
|
|
48
77
|
body[:text] = text if text
|
|
49
78
|
body[:html] = html if html
|
|
50
79
|
body[:cc] = wrap(cc).map { |a| normalize_address(a) } if cc
|
|
@@ -104,6 +133,10 @@ module Cloudflare
|
|
|
104
133
|
response = request(:post, path, body)
|
|
105
134
|
payload[:status] = response.status
|
|
106
135
|
payload[:message_id] = response.message_id
|
|
136
|
+
payload[:delivered] = response.delivered
|
|
137
|
+
payload[:queued] = response.queued
|
|
138
|
+
payload[:permanent_bounces] = response.permanent_bounces
|
|
139
|
+
payload[:suppressed_recipients] = response.suppressed_recipients
|
|
107
140
|
response
|
|
108
141
|
end
|
|
109
142
|
end
|
|
@@ -125,7 +158,10 @@ module Cloudflare
|
|
|
125
158
|
attempts += 1
|
|
126
159
|
do_request(method, uri, body)
|
|
127
160
|
rescue *RETRYABLE_NETWORK => e
|
|
128
|
-
|
|
161
|
+
unless PRE_SEND_NETWORK.any? { |type| e.is_a?(type) } || @retry_ambiguous
|
|
162
|
+
raise NetworkError.new("provider response could not be read; delivery outcome is unknown; automatic retry disabled"), cause: nil
|
|
163
|
+
end
|
|
164
|
+
raise NetworkError.new("provider request could not be completed; request outcome is unknown"), cause: nil if attempts > @retries
|
|
129
165
|
log_retry(attempts, e)
|
|
130
166
|
sleep(backoff); backoff *= 2
|
|
131
167
|
retry
|
|
@@ -135,7 +171,7 @@ module Cloudflare
|
|
|
135
171
|
sleep(retry_after_from(e, backoff)); backoff *= 2
|
|
136
172
|
retry
|
|
137
173
|
rescue ServerError => e
|
|
138
|
-
raise
|
|
174
|
+
raise unless @retry_ambiguous && attempts <= @retries
|
|
139
175
|
log_retry(attempts, e)
|
|
140
176
|
sleep(backoff); backoff *= 2
|
|
141
177
|
retry
|
|
@@ -145,7 +181,15 @@ module Cloudflare
|
|
|
145
181
|
def retry_after_from(error, fallback_backoff)
|
|
146
182
|
header = error.response.is_a?(Hash) ? error.response["retry_after"] : nil
|
|
147
183
|
value = header || fallback_backoff
|
|
148
|
-
seconds = value.
|
|
184
|
+
seconds = if value.to_s.match?(/\A\d+(?:\.\d+)?\z/)
|
|
185
|
+
value.to_f
|
|
186
|
+
else
|
|
187
|
+
begin
|
|
188
|
+
Time.httpdate(value.to_s) - Time.now
|
|
189
|
+
rescue ArgumentError
|
|
190
|
+
fallback_backoff
|
|
191
|
+
end
|
|
192
|
+
end
|
|
149
193
|
return fallback_backoff if seconds <= 0
|
|
150
194
|
[seconds, @max_retry_after].min
|
|
151
195
|
end
|
|
@@ -155,6 +199,8 @@ module Cloudflare
|
|
|
155
199
|
http.use_ssl = (uri.scheme == "https")
|
|
156
200
|
http.open_timeout = @timeout
|
|
157
201
|
http.read_timeout = @timeout
|
|
202
|
+
http.write_timeout = @timeout
|
|
203
|
+
http.max_retries = 0
|
|
158
204
|
|
|
159
205
|
req_class = { post: Net::HTTP::Post, get: Net::HTTP::Get }.fetch(method)
|
|
160
206
|
req = req_class.new(uri.request_uri)
|
|
@@ -164,13 +210,32 @@ module Cloudflare
|
|
|
164
210
|
req["User-Agent"] = "cloudflare-email-ruby/#{Cloudflare::Email::VERSION}"
|
|
165
211
|
req.body = JSON.generate(body) if body
|
|
166
212
|
|
|
167
|
-
|
|
168
|
-
|
|
213
|
+
raw = +"".b
|
|
214
|
+
response = nil
|
|
215
|
+
byte_limit = response_byte_limit(uri)
|
|
216
|
+
# Bound each network attempt, including trickling headers/body. Parsing,
|
|
217
|
+
# queue handlers, and database transactions are outside this deadline.
|
|
218
|
+
Timeout.timeout(@total_timeout) do
|
|
219
|
+
http.request(req) do |incoming|
|
|
220
|
+
response = incoming
|
|
221
|
+
incoming.read_body do |chunk|
|
|
222
|
+
# Net::HTTP yields decompressed bytes, so compressed expansion is
|
|
223
|
+
# subject to the same budget. Check before extending the buffer.
|
|
224
|
+
raise IOError, "provider response exceeds byte limit" if raw.bytesize + chunk.bytesize > byte_limit
|
|
225
|
+
raw << chunk
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
handle_response(response, raw)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def response_byte_limit(_uri)
|
|
233
|
+
@max_response_bytes
|
|
169
234
|
end
|
|
170
235
|
|
|
171
|
-
def handle_response(response)
|
|
236
|
+
def handle_response(response, raw = response.body)
|
|
172
237
|
status = response.code.to_i
|
|
173
|
-
body = parse_body(
|
|
238
|
+
body = parse_body(raw)
|
|
174
239
|
retry_after = response["Retry-After"]
|
|
175
240
|
|
|
176
241
|
# Stash Retry-After on the error response so retry logic can use it.
|
|
@@ -178,6 +243,15 @@ module Cloudflare
|
|
|
178
243
|
|
|
179
244
|
case status
|
|
180
245
|
when 200..299
|
|
246
|
+
if body.is_a?(Hash) && body.key?("success") && body["success"] != true
|
|
247
|
+
raise Error.new(extract_message(body), status: status, response: body)
|
|
248
|
+
end
|
|
249
|
+
unless body.is_a?(Hash) && body["result"].is_a?(Hash)
|
|
250
|
+
raise Error.new("invalid successful API response; request outcome is unknown", status: status, response: body)
|
|
251
|
+
end
|
|
252
|
+
if body["errors"].is_a?(Array) && body["errors"].any?
|
|
253
|
+
raise Error.new(extract_message(body), status: status, response: body)
|
|
254
|
+
end
|
|
181
255
|
Response.new(body, status: status)
|
|
182
256
|
when 400, 422
|
|
183
257
|
raise ValidationError.new(extract_message(body), status: status, response: body)
|
|
@@ -197,7 +271,7 @@ module Cloudflare
|
|
|
197
271
|
return {} if raw.nil? || raw.empty?
|
|
198
272
|
JSON.parse(raw)
|
|
199
273
|
rescue JSON::ParserError
|
|
200
|
-
{ "errors" => [{ "message" =>
|
|
274
|
+
{ "errors" => [{ "message" => "invalid JSON API response; request outcome is unknown" }] }
|
|
201
275
|
end
|
|
202
276
|
|
|
203
277
|
def extract_message(body)
|
|
@@ -209,7 +283,8 @@ module Cloudflare
|
|
|
209
283
|
|
|
210
284
|
def log_retry(attempt, error)
|
|
211
285
|
return unless @logger
|
|
212
|
-
|
|
286
|
+
# Provider messages can echo message content or other sensitive data.
|
|
287
|
+
@logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}")
|
|
213
288
|
end
|
|
214
289
|
end
|
|
215
290
|
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,11 @@ module Cloudflare
|
|
|
25
25
|
base_url: settings[:base_url] || Cloudflare::Email::Client::DEFAULT_BASE_URL,
|
|
26
26
|
retries: settings.fetch(:retries, Cloudflare::Email::Client::DEFAULT_RETRIES),
|
|
27
27
|
timeout: settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT),
|
|
28
|
+
total_timeout: settings.fetch(:total_timeout, settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT)),
|
|
29
|
+
max_response_bytes: settings.fetch(:max_response_bytes, Cloudflare::Email::Client::DEFAULT_MAX_RESPONSE_BYTES),
|
|
30
|
+
initial_backoff: settings.fetch(:initial_backoff, Cloudflare::Email::Client::DEFAULT_BACKOFF),
|
|
31
|
+
max_retry_after: settings.fetch(:max_retry_after, Cloudflare::Email::Client::MAX_RETRY_AFTER),
|
|
32
|
+
retry_ambiguous: settings.fetch(:retry_ambiguous, false),
|
|
28
33
|
logger: settings[:logger],
|
|
29
34
|
)
|
|
30
35
|
|
|
@@ -6,8 +6,8 @@ module Cloudflare
|
|
|
6
6
|
# `bin/rails cloudflare:email:deploy_worker` — uploads the Worker
|
|
7
7
|
# script + both secrets via the Cloudflare API. No wrangler required.
|
|
8
8
|
class DeployWorkerTask < TaskBase
|
|
9
|
-
def self.call(script_path: nil, ingress_url: nil, io: $stdout)
|
|
10
|
-
new(io: io, script_path: script_path, ingress_url: ingress_url).call
|
|
9
|
+
def self.call(script_path: nil, ingress_url: nil, delivery_mode: "durable", io: $stdout)
|
|
10
|
+
new(io: io, script_path: script_path, ingress_url: ingress_url, delivery_mode: delivery_mode).call
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
protected
|
|
@@ -20,12 +20,15 @@ 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
|
)
|
|
26
29
|
|
|
27
30
|
say " Deploying Worker '#{deployer.script_name}'..."
|
|
28
|
-
deployer.deploy(script_path: path)
|
|
31
|
+
deployer.deploy(script_path: path, delivery_mode: opts[:delivery_mode])
|
|
29
32
|
say " ✓ Worker script deployed"
|
|
30
33
|
|
|
31
34
|
deployer.put_secret("INGRESS_SECRET", ingress_secret)
|
|
@@ -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
|
|
@@ -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,39 @@ 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?(Cloudflare::Email::Management::Engine)
|
|
36
|
+
Rails.autoloaders.main.ignore(
|
|
37
|
+
File.expand_path("../../../app/controllers/cloudflare/email/management", __dir__),
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
unless defined?(::ActionMailbox::Engine)
|
|
41
|
+
Rails.autoloaders.main.ignore(
|
|
42
|
+
File.expand_path("../../../app/controllers/cloudflare/email/ingress_controller.rb", __dir__),
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
16
47
|
initializer "cloudflare-email.routes" do |app|
|
|
48
|
+
next unless defined?(::ActionMailbox::Engine)
|
|
49
|
+
|
|
17
50
|
app.routes.append do
|
|
18
51
|
post "/rails/action_mailbox/cloudflare/inbound_emails",
|
|
19
52
|
to: "cloudflare/email/ingress#create",
|