cloudflare-email 0.2.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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +34 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  7. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  8. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  9. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  10. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  11. data/docs/activerecord-tenanted.md +46 -0
  12. data/docs/architecture.md +13 -3
  13. data/docs/custom-ingress.md +207 -0
  14. data/docs/delivery-events.md +9 -0
  15. data/docs/features.md +36 -5
  16. data/docs/getting-started.md +18 -8
  17. data/docs/mailboxes.md +67 -3
  18. data/docs/management-engine.md +190 -0
  19. data/docs/routing-deliveries.md +94 -0
  20. data/docs/routing-diagnostics.md +68 -0
  21. data/docs/troubleshooting.md +4 -3
  22. data/docs/upgrading-0.2.md +5 -0
  23. data/docs/upgrading-0.3.md +55 -0
  24. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  25. data/docs/verification/2026-09-12-destination-context.md +46 -0
  26. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  27. data/lib/cloudflare/email/active_record/base.rb +13 -1
  28. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  29. data/lib/cloudflare/email/check_route_task.rb +21 -0
  30. data/lib/cloudflare/email/client.rb +45 -10
  31. data/lib/cloudflare/email/delivery_method.rb +2 -0
  32. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  33. data/lib/cloudflare/email/engine.rb +5 -0
  34. data/lib/cloudflare/email/envelope.rb +2 -2
  35. data/lib/cloudflare/email/event_consumer.rb +12 -3
  36. data/lib/cloudflare/email/ingress.rb +115 -0
  37. data/lib/cloudflare/email/mailboxes/models.rb +19 -2
  38. data/lib/cloudflare/email/mailboxes/service.rb +99 -15
  39. data/lib/cloudflare/email/management/adapter.rb +31 -0
  40. data/lib/cloudflare/email/management/configuration.rb +17 -0
  41. data/lib/cloudflare/email/management/engine.rb +15 -0
  42. data/lib/cloudflare/email/management/management.css +68 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/verification.rb +17 -9
  49. data/lib/cloudflare/email/version.rb +1 -1
  50. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  51. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  52. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  53. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  54. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
  55. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  56. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  57. data/lib/tasks/cloudflare_email.rake +2 -1
  58. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  59. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  60. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  61. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  62. data/templates/deploy-to-cloudflare/README.md +84 -0
  63. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  64. data/templates/deploy-to-cloudflare/package.json +36 -0
  65. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  66. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  67. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  68. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  69. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  70. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  71. data/templates/worker/README.md +180 -15
  72. data/templates/worker/docs/durable-inbound.md +149 -0
  73. data/templates/worker/package-lock.json +2 -2
  74. data/templates/worker/package.json +1 -1
  75. data/templates/worker/src/index.js +331 -38
  76. data/templates/worker/test/durable.test.ts +193 -0
  77. data/templates/worker/test/index.test.ts +78 -20
  78. data/templates/worker/test/relay.test.ts +226 -0
  79. data/templates/worker/wrangler.toml +50 -0
  80. metadata +49 -3
@@ -4,6 +4,16 @@ require "cloudflare/email/envelope"
4
4
  module Cloudflare
5
5
  module Email
6
6
  module Mailboxes
7
+ # A routing snapshot, not a model or an authorization token. Use it inside
8
+ # the yielded tenant context; later jobs must resolve/check policy again.
9
+ Destination = Struct.new(:tenant_key, :mailbox_id, :address_id,
10
+ :receiving_domain_id, :recipient, :owner_ref, :catch_all, keyword_init: true) do
11
+ def initialize(**attributes)
12
+ super(**{ catch_all: false }.merge(attributes).transform_values { |value| value.is_a?(String) ? value.dup.freeze : value })
13
+ freeze
14
+ end
15
+ end
16
+
7
17
  class << self
8
18
  # Call after host authorization. The directory is a control-plane API,
9
19
  # not a public endpoint accepting arbitrary customer domain claims.
@@ -27,16 +37,23 @@ module Cloudflare
27
37
 
28
38
  # Only call after verifying the complete ingress HMAC. Resolve before
29
39
  # ActionMailbox or ActiveStorage accesses a tenant connection.
30
- def receive(recipient:)
31
- raise ArgumentError, "persistence block required" unless block_given?
32
- address = canonical_address(recipient)
33
- directory = ReceivingDomain.find_by(domain: address.split("@", 2).last, state: "active")
34
- raise Unavailable, "receiving domain unavailable" unless directory
35
- for_tenant(directory.tenant_key) do |session|
36
- session.receive(address, directory) { yield }
40
+ def receive(recipient:, &block)
41
+ raise ArgumentError, "persistence block required" unless block
42
+ in_recipient_tenant(recipient) do |session, address, directory|
43
+ session.receive(address, directory) do |destination|
44
+ # Preserve strict callbacks accepted before destinations were yielded.
45
+ block.lambda? && block.arity.zero? ? block.call : block.call(destination)
46
+ end
47
+ end
48
+ end
49
+
50
+ # Lookup only: the host owns persistence and the block's return value.
51
+ # Verify ingress before calling this API. No ActionMailbox is required.
52
+ def with_recipient(recipient:)
53
+ raise ArgumentError, "a block is required" unless block_given?
54
+ in_recipient_tenant(recipient) do |session, address, directory|
55
+ session.with_recipient(address, directory) { |destination| yield destination }
37
56
  end
38
- rescue ::ActiveRecord::RecordNotFound
39
- raise Unavailable, "mailbox address unavailable"
40
57
  end
41
58
 
42
59
  def canonical_address(value)
@@ -45,6 +62,17 @@ module Cloudflare
45
62
  end
46
63
  value.downcase
47
64
  end
65
+
66
+ private
67
+
68
+ def in_recipient_tenant(recipient)
69
+ address = canonical_address(recipient)
70
+ directory = ReceivingDomain.find_by(domain: address.split("@", 2).last, state: "active")
71
+ raise Unavailable, "receiving domain unavailable" unless directory
72
+ for_tenant(directory.tenant_key) do |session|
73
+ yield session, address, directory
74
+ end
75
+ end
48
76
  end
49
77
 
50
78
  class Session
@@ -100,6 +128,29 @@ module Cloudflare
100
128
  activate_address!(address.id, evidence: "Cloudflare address rule provisioned for Worker #{worker_name}")
101
129
  end
102
130
 
131
+ # This records the host's verified provider routing decision; it does
132
+ # not provision Cloudflare DNS/rules or authorize arbitrary From values.
133
+ def enable_catch_all(address_id, evidence:)
134
+ context!
135
+ catch_all_schema!
136
+ raise ArgumentError, "catch-all route verification evidence is required" if evidence.to_s.strip.empty?
137
+ Address.transaction do
138
+ address = Address.where(tenant_key: tenant_key, state: "active").find(address_id)
139
+ active_mailbox!(address.mailbox_id)
140
+ active_domain!(address)
141
+ address.update!(catch_all: true, catch_all_evidence: evidence)
142
+ address
143
+ end
144
+ end
145
+
146
+ def disable_catch_all(address_id)
147
+ context!
148
+ catch_all_schema!
149
+ # Disabling is permitted even after suspension; retain the previous
150
+ # verification evidence for operator inspection.
151
+ Address.where(tenant_key: tenant_key).find(address_id).tap { |address| address.update!(catch_all: false) }
152
+ end
153
+
103
154
  def suspend(mailbox_id)
104
155
  mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "suspended") }
105
156
  end
@@ -237,23 +288,56 @@ module Cloudflare
237
288
  context!
238
289
  ensure_storage_connection! if defined?(::ActionMailbox::InboundEmail)
239
290
  Mailbox.transaction do
240
- destination = Address.where(tenant_key: tenant_key, address: address, receiving_domain_id: directory.id, state: "active").first
241
- raise Unavailable, "mailbox address unavailable" unless destination
242
- mailbox = active_mailbox!(destination.mailbox_id)
243
- active_domain!(destination)
244
- inbound = yield
245
- Message.create_or_find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id,
291
+ destination = resolve_destination(address, directory)
292
+ inbound = yield destination
293
+ Message.create_or_find_by!(tenant_key: tenant_key, mailbox_id: destination.mailbox_id,
246
294
  inbound_email_id: inbound.id) { |row| row.recipient = address } if inbound
247
295
  inbound
248
296
  end
249
297
  end
250
298
 
299
+ def with_recipient(address, directory)
300
+ context!
301
+ yield resolve_destination(address, directory)
302
+ end
303
+
251
304
  private
252
305
 
306
+ def resolve_destination(address, directory)
307
+ destination = Address.where(tenant_key: tenant_key, address: address,
308
+ receiving_domain_id: directory.id).first
309
+ fallback = destination.nil?
310
+ if fallback && catch_all_schema_available?
311
+ destination = Address.where(tenant_key: tenant_key, receiving_domain_id: directory.id,
312
+ catch_all: true, state: "active").first
313
+ end
314
+ # An exact pending/suspended address intentionally reserves its name.
315
+ # It must never fall through to another mailbox's catch-all.
316
+ raise Unavailable, "mailbox address unavailable" unless destination&.state == "active"
317
+ mailbox = active_mailbox!(destination.mailbox_id)
318
+ active_domain!(destination)
319
+ Destination.new(tenant_key: tenant_key, mailbox_id: mailbox.id,
320
+ address_id: destination.id, receiving_domain_id: directory.id,
321
+ recipient: address, owner_ref: mailbox.owner_ref, catch_all: fallback)
322
+ rescue ::ActiveRecord::RecordNotFound
323
+ raise Unavailable, "mailbox address unavailable"
324
+ end
325
+
253
326
  def context!
254
327
  raise ConfigurationError, "mailbox session used outside its tenant context" unless Tenancy.require_context! == tenant_key
255
328
  end
256
329
 
330
+ def catch_all_schema_available?
331
+ Address.connection.column_exists?(Address.table_name, :catch_all) &&
332
+ Address.connection.column_exists?(Address.table_name, :catch_all_evidence)
333
+ end
334
+
335
+ def catch_all_schema!
336
+ unless catch_all_schema_available?
337
+ raise ConfigurationError, "run cloudflare:email:mailboxes:catch_all migrations for this tenant first"
338
+ end
339
+ end
340
+
257
341
  def ensure_storage_connection!
258
342
  unless defined?(::ActionMailbox::InboundEmail) && ::ActionMailbox::InboundEmail.connection_pool == Mailbox.connection_pool &&
259
343
  ::ActiveStorage::Blob.connection_pool == Mailbox.connection_pool
@@ -0,0 +1,31 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Management
4
+ # A host supplies authentication, mailbox ownership and domain entitlement.
5
+ # Defaults deliberately grant no access. No principal comes from params.
6
+ class Adapter
7
+ attr_reader :controller
8
+
9
+ def initialize(controller = nil)
10
+ @controller = controller
11
+ end
12
+
13
+ def authenticate! = false
14
+ def tenant_key = nil
15
+ def mailboxes(session) = session.mailboxes.none
16
+ def allowed?(action, mailbox = nil) = false
17
+ def domains(session) = []
18
+
19
+ # Override these two hooks when the host keeps a linked product model or
20
+ # uses a deployment policy to activate/provision verified addresses.
21
+ def create_mailbox(session, name:, address:)
22
+ session.create(name: name, address: address)
23
+ end
24
+
25
+ def add_address(session, mailbox, address:)
26
+ session.add_address(mailbox.id, address: address)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Management
4
+ class Configuration
5
+ attr_accessor :adapter, :back_path
6
+ end
7
+
8
+ def self.configuration
9
+ @configuration ||= Configuration.new
10
+ end
11
+
12
+ def self.configure
13
+ yield configuration
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,15 @@
1
+ require "rails/engine"
2
+ require "action_controller/railtie"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Management
7
+ class Engine < ::Rails::Engine
8
+ isolate_namespace Cloudflare::Email::Management
9
+ config.root = File.expand_path("../../../..", __dir__)
10
+
11
+ config.paths["config/routes.rb"] = "lib/cloudflare/email/management/routes.rb"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ :root { color-scheme: light; --ink: #172c35; --muted: #536871; --line: #dce4e6; --accent: #12655c; --surface: #fff; }
2
+ * { box-sizing: border-box; }
3
+ body { margin: 0; background: #f5f7f7; color: var(--ink); font: 16px/1.55 system-ui, sans-serif; }
4
+ a { color: var(--accent); text-underline-offset: 3px; }
5
+ a:hover { text-decoration-thickness: 2px; }
6
+ :focus-visible { outline: 3px solid #b66a17; outline-offset: 4px; }
7
+ .shell { width: min(1160px, 100% - 48px); margin: 0 auto; }
8
+ .site-header { background: var(--surface); border-bottom: 1px solid var(--line); }
9
+ .header-content { display: flex; justify-content: space-between; align-items: center; gap: 16px; min-height: 76px; }
10
+ .brand { color: var(--ink); font-weight: 750; font-size: 18px; text-decoration: none; }
11
+ .header-caption, .muted, .field-help { color: var(--muted); }
12
+ main.shell { padding-top: 32px; padding-bottom: 64px; }
13
+ h1, h2, h3, p { margin: 0; }
14
+ h1 { font-size: clamp(26px, 4vw, 36px); line-height: 1.2; letter-spacing: -.035em; overflow-wrap: anywhere; }
15
+ h2 { font-size: 17px; line-height: 1.4; }
16
+ h3 { font-size: 17px; }
17
+ .small, .field-help { font-size: 13px; }
18
+ .eyebrow { color: var(--accent); font-size: 12px; font-weight: 750; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
19
+ .page-heading { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 28px; }
20
+ .page-heading .muted { margin-top: 12px; max-width: 640px; }
21
+ .workspace-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
22
+ .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; min-width: 0; }
23
+ .panel-heading { padding: 20px 24px; border-bottom: 1px solid var(--line); }
24
+ .panel-body { padding: 24px; }
25
+ .stack { display: flex; flex-direction: column; gap: 20px; }
26
+ .field { display: flex; flex-direction: column; gap: 8px; }
27
+ label { font-weight: 650; font-size: 14px; }
28
+ input[type="text"], input[type="email"] { width: 100%; border: 1px solid #a6b7be; border-radius: 7px; padding: 11px 12px; background: #fff; color: var(--ink); font: inherit; }
29
+ .button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 9px 15px; border: 1px solid var(--accent); border-radius: 7px; background: var(--accent); color: #fff; font: inherit; font-size: 14px; font-weight: 650; text-decoration: none; cursor: pointer; text-align: center; }
30
+ .button:hover { filter: brightness(.93); }
31
+ .button-secondary { background: #fff; color: var(--ink); border-color: #a6b7be; }
32
+ .record-list { list-style: none; margin: 0; padding: 0; }
33
+ .record-row { padding: 20px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
34
+ .record-row + .record-row { border-top: 1px solid var(--line); }
35
+ .record-content { min-width: 0; }
36
+ .record-title { font-weight: 650; overflow-wrap: anywhere; }
37
+ .record-content p { margin-top: 4px; }
38
+ .badge { display: inline-block; border: 1px solid var(--line); border-radius: 5px; background: #f3f6f6; color: #364f58; padding: 3px 8px; font-size: 12px; font-weight: 650; white-space: nowrap; }
39
+ .status-list { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
40
+ .address-row { align-items: flex-start; flex-direction: column; gap: 8px; }
41
+ .empty-state { padding: 48px 24px; text-align: center; }
42
+ .empty-state p { color: var(--muted); max-width: 340px; margin: 8px auto 0; }
43
+ .pagination { padding: 16px 24px; border-top: 1px solid var(--line); }
44
+ .actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
45
+ .actions form { margin: 0; }
46
+ .breadcrumbs { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 14px; margin-bottom: 28px; overflow-wrap: anywhere; }
47
+ .notice { border: 1px solid #a7ccc0; border-radius: 8px; background: #edf8f2; color: #205340; padding: 14px 18px; margin-bottom: 24px; overflow-wrap: anywhere; }
48
+ .notice-error { border-color: #e8b9b3; background: #fff2ef; color: #8d2a21; }
49
+ .message-metadata { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 10px 20px; margin: 0; font-size: 14px; }
50
+ .message-metadata dt { color: var(--muted); }
51
+ .message-metadata dd { margin: 0; overflow-wrap: anywhere; }
52
+ .message-body { border-top: 1px solid var(--line); padding: 28px 24px; }
53
+ .message-body pre { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; margin: 0; }
54
+ .attachments { border-top: 1px solid var(--line); }
55
+ .attachments p { margin-top: 8px; }
56
+ .attachments li { overflow-wrap: anywhere; }
57
+ .skip-link { position: absolute; left: 16px; top: -100px; background: #fff; padding: 12px; z-index: 1; }
58
+ .skip-link:focus { top: 12px; }
59
+ @media (max-width: 760px) {
60
+ .shell { width: calc(100% - 32px); }
61
+ .header-content { min-height: 64px; }
62
+ .header-caption { display: none; }
63
+ .workspace-grid { grid-template-columns: minmax(0, 1fr); }
64
+ .page-heading { align-items: flex-start; flex-direction: column; }
65
+ .panel-heading, .panel-body, .record-row, .message-body { padding: 20px; }
66
+ .record-row { align-items: flex-start; }
67
+ .message-metadata { grid-template-columns: 64px minmax(0, 1fr); gap: 10px; }
68
+ }
@@ -0,0 +1,14 @@
1
+ Cloudflare::Email::Management::Engine.routes.draw do
2
+ get "style.css", to: "styles#show", as: :style
3
+ root to: "mailboxes#index"
4
+ resources :mailboxes, only: [:index, :show, :create] do
5
+ member do
6
+ post :aliases, action: :add_address
7
+ post :suspend
8
+ post :resume
9
+ get "messages/:message_id", action: :message, as: :message
10
+ post :mark_read
11
+ post :archive
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ # Explicit opt-in: require this before Rails initializes, then configure the
2
+ # adapter in an initializer. Loading the core gem never mounts this interface.
3
+ require "rails/engine"
4
+ require "cloudflare-email"
5
+ require "cloudflare/email/management/adapter"
6
+ require "cloudflare/email/management/configuration"
7
+ require "cloudflare/email/management/engine"
@@ -0,0 +1,77 @@
1
+ require "json"
2
+ require "cloudflare/email/signing"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # Signed assertions supplied by the forwarding integration, not proof that
7
+ # the original sender passed SPF/DKIM/DMARC. Never read these from MIME.
8
+ module ProviderMetadata
9
+ METADATA_KEY = "cloudflare_email_provider_metadata".freeze
10
+ MAX_ENCODED_BYTES = 16 * 1024
11
+ MAX_DEPTH = 8
12
+ SOURCE = /\A[a-z][a-z0-9_.-]{0,127}\z/
13
+
14
+ def self.valid?(value)
15
+ value.is_a?(Hash) && value.size == 2 && value.key?("data") && value.key?("source") &&
16
+ value["source"].is_a?(String) && SOURCE.match?(value["source"]) &&
17
+ value["data"].is_a?(Hash) && json_value?(value, 0)
18
+ end
19
+
20
+ def self.json_value?(value, depth)
21
+ case value
22
+ when Hash
23
+ depth < MAX_DEPTH && value.all? { |key, item| key.is_a?(String) && utf8?(key) && json_value?(item, depth + 1) }
24
+ when Array
25
+ depth < MAX_DEPTH && value.all? { |item| json_value?(item, depth + 1) }
26
+ when String then utf8?(value)
27
+ when Integer, TrueClass, FalseClass, NilClass then true
28
+ when Float then value.finite?
29
+ else false
30
+ end
31
+ end
32
+
33
+ def self.utf8?(value)
34
+ value.encode(Encoding::UTF_8).valid_encoding?
35
+ rescue EncodingError
36
+ false
37
+ end
38
+
39
+ def self.encode(source:, data:)
40
+ value = {"source" => source, "data" => data}
41
+ raise ArgumentError, "invalid provider metadata" unless valid?(value)
42
+ encoded = Signing.base64url_encode(JSON.generate(value))
43
+ raise ArgumentError, "provider metadata exceeds size limit" if encoded.bytesize > MAX_ENCODED_BYTES
44
+ encoded
45
+ end
46
+
47
+ def self.decode(encoded)
48
+ return unless encoded.is_a?(String) && encoded.bytesize.between?(1, MAX_ENCODED_BYTES)
49
+ return unless /\A[A-Za-z0-9_-]+\z/.match?(encoded)
50
+ bytes = Signing.base64url_decode(encoded)
51
+ return unless Signing.base64url_encode(bytes) == encoded
52
+ text = bytes.force_encoding(Encoding::UTF_8)
53
+ return unless text.valid_encoding?
54
+ value = JSON.parse(text, max_nesting: MAX_DEPTH)
55
+ value if valid?(value)
56
+ rescue JSON::ParserError, ArgumentError
57
+ nil
58
+ end
59
+
60
+ def self.for(inbound_email)
61
+ metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
62
+ return unless metadata.is_a?(Hash) && metadata["signature_version"] == 3
63
+ value = metadata.reject { |key, _| key == "signature_version" }
64
+ value if valid?(value)
65
+ end
66
+
67
+ # Stable receipt identity if JSON object members arrive in another order.
68
+ def self.canonical(value)
69
+ case value
70
+ when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
71
+ when Array then value.map { |item| canonical(item) }
72
+ else value
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "timeout"
6
+ require "time"
7
+ require "digest"
8
+ require "cloudflare/email/message_id"
9
+
10
+ module Cloudflare
11
+ module Email
12
+ # Optional, authenticated, delivered-only evidence. This is not a webhook.
13
+ module RoutingAnalytics
14
+ class Error < StandardError; end
15
+
16
+ class Evidence
17
+ attr_reader :payload
18
+
19
+ def initialize(payload)
20
+ @payload = JSON.parse(JSON.generate(payload))
21
+ validate!
22
+ freeze_value(@payload)
23
+ freeze
24
+ rescue JSON::ParserError, JSON::GeneratorError, KeyError, ArgumentError, TypeError
25
+ raise Error, "Routing analytics evidence is malformed", cause: nil
26
+ end
27
+
28
+ def event = payload.fetch("event")
29
+ def account_id = payload.fetch("account_id")
30
+ def zone_id = payload.fetch("zone_id")
31
+ def message_id = MessageId.normalize(event.fetch("messageId"))
32
+ def occurred_at = Time.iso8601(event.fetch("datetime"))
33
+
34
+ def identity
35
+ Digest::SHA256.hexdigest(JSON.generate([account_id, zone_id, event.fetch("sessionId"),
36
+ message_id, occurred_at.iso8601(9), event.fetch("status")]))
37
+ end
38
+
39
+ # Query windows change between polls; the authentic row must not change.
40
+ def same_event?(other)
41
+ account_id == other.account_id && zone_id == other.zone_id && event == other.event
42
+ end
43
+
44
+ private
45
+
46
+ def validate!
47
+ unless payload.is_a?(Hash) && payload["source"] == "cloudflare_routing_analytics" &&
48
+ payload["account_id"].is_a?(String) && !payload["account_id"].empty? &&
49
+ payload["zone_id"].is_a?(String) && payload["zone_id"].match?(/\A[a-f0-9]{32}\z/) &&
50
+ event.is_a?(Hash) && event["eventType"] == "newEmail" && event["status"] == "delivered" &&
51
+ event["isNDR"] == 0 && event["isLastEvent"] == 1 && event["sampleInterval"] == 1 &&
52
+ event["sessionId"].is_a?(String) && !event["sessionId"].strip.empty? &&
53
+ event["from"].is_a?(String) && !event["from"].strip.empty? &&
54
+ event["messageId"].is_a?(String) && !message_id.empty?
55
+ raise Error, "Routing analytics evidence is not an unsampled final delivery"
56
+ end
57
+ start = Time.iso8601(payload.fetch("query_started_at"))
58
+ finish = Time.iso8601(payload.fetch("query_finished_at"))
59
+ unless start <= occurred_at && occurred_at <= finish && finish <= Time.now.utc
60
+ raise Error, "Routing analytics evidence is outside its query window"
61
+ end
62
+ response = payload.fetch("response")
63
+ viewer = response["data"] if response.is_a?(Hash)
64
+ viewer = viewer["viewer"] if viewer.is_a?(Hash)
65
+ zones = viewer["zones"] if viewer.is_a?(Hash)
66
+ unless response.is_a?(Hash) && response["success"] != false &&
67
+ (response["errors"].nil? || response["errors"] == []) && zones.is_a?(Array) && zones.one? &&
68
+ zones.first.is_a?(Hash) && zones.first["zoneTag"] == zone_id
69
+ raise Error, "Routing analytics evidence response has unexpected zone"
70
+ end
71
+ rows = zones.first["emailRoutingAdaptive"]
72
+ unless rows.is_a?(Array) && rows.length < 100 && rows.all? { |row| row.is_a?(Hash) } && rows.include?(event)
73
+ raise Error, "Routing analytics evidence response is malformed or truncated"
74
+ end
75
+ end
76
+
77
+ def freeze_value(value)
78
+ value.each { |key, item| freeze_value(key); freeze_value(item) } if value.is_a?(Hash)
79
+ value.each { |item| freeze_value(item) } if value.is_a?(Array)
80
+ value.freeze
81
+ end
82
+ end
83
+
84
+ class Client
85
+ REQUEST_TIMEOUT = 5
86
+ MAX_RESPONSE_BYTES = 1_000_000
87
+ QUERY = <<~GRAPHQL.freeze
88
+ query($zone: string!, $ids: [string!], $start: Time!, $finish: Time!) {
89
+ viewer { zones(filter: {zoneTag: $zone}) {
90
+ zoneTag
91
+ emailRoutingAdaptive(limit: 100, orderBy: [datetime_ASC], filter: {
92
+ messageId_in: $ids, datetime_geq: $start, datetime_leq: $finish
93
+ }) { messageId sessionId datetime eventType status isNDR isLastEvent sampleInterval from to action }
94
+ } }
95
+ }
96
+ GRAPHQL
97
+
98
+ def initialize(account_id:, zone_id:, api_token:)
99
+ unless account_id.is_a?(String) && !account_id.empty? && zone_id.is_a?(String) &&
100
+ zone_id.match?(/\A[a-f0-9]{32}\z/) && api_token.is_a?(String) && !api_token.strip.empty? &&
101
+ !api_token.match?(/[\r\n]/)
102
+ raise ArgumentError, "Routing analytics requires an account, zone ID and API token"
103
+ end
104
+ @account, @zone, @token = account_id.dup.freeze, zone_id.dup.freeze, api_token.dup.freeze
105
+ end
106
+
107
+ def inspect = "#<#{self.class} credentials=[FILTERED]>"
108
+
109
+ def verify_account!
110
+ @verified = false
111
+ result = request("/zones/#{@zone}")["result"]
112
+ unless result.is_a?(Hash) && result["id"] == @zone && result["account"].is_a?(Hash) &&
113
+ result["account"]["id"] == @account
114
+ raise Error, "Routing analytics zone/account mismatch"
115
+ end
116
+ @verified = true
117
+ end
118
+
119
+ def delivery_evidence(message_id:, since:, until_time:)
120
+ raise Error, "Verify routing analytics zone/account first" unless @verified
121
+ id = MessageId.normalize(message_id)
122
+ unless !id.empty? && since.is_a?(Time) && until_time.is_a?(Time) && since <= until_time && until_time <= Time.now.utc
123
+ raise ArgumentError, "A message ID and valid historical Time window are required"
124
+ end
125
+ # Analytics timestamps have second precision, unlike saved attempts.
126
+ start, finish = Time.at(since.to_i).utc.iso8601, until_time.getutc.iso8601
127
+ data = request("/graphql", query: QUERY, variables: {
128
+ zone: @zone, ids: [id, "<#{id}>"], start: start, finish: finish
129
+ })
130
+ viewer = data["data"]
131
+ viewer = viewer["viewer"] if viewer.is_a?(Hash)
132
+ zones = viewer["zones"] if viewer.is_a?(Hash)
133
+ unless zones.is_a?(Array) && zones.one? && zones.first.is_a?(Hash) && zones.first["zoneTag"] == @zone
134
+ raise Error, "Routing analytics response has unexpected zone"
135
+ end
136
+ rows = zones.first["emailRoutingAdaptive"]
137
+ unless rows.is_a?(Array) && rows.length < 100 && rows.all? { |row| row.is_a?(Hash) }
138
+ raise Error, "Routing analytics response is malformed or truncated"
139
+ end
140
+ final_rows = rows.select { |row| row["eventType"] == "newEmail" && row["isNDR"] == 0 &&
141
+ row["isLastEvent"] == 1 && row["sampleInterval"] == 1 }
142
+ final_rows.group_by { |row| [MessageId.normalize(row["messageId"]), row["sessionId"]] }.each_value do |events|
143
+ if events.uniq.length > 1
144
+ raise Error, "Routing analytics response contains contradictory final events"
145
+ end
146
+ end
147
+ rows.filter_map do |row|
148
+ next unless row["eventType"] == "newEmail" && row["status"] == "delivered" &&
149
+ row["isNDR"] == 0 && row["isLastEvent"] == 1 && row["sampleInterval"] == 1
150
+ evidence = Evidence.new("source" => "cloudflare_routing_analytics", "account_id" => @account,
151
+ "zone_id" => @zone, "query_started_at" => start, "query_finished_at" => finish,
152
+ "response" => data, "event" => row)
153
+ raise Error, "Routing analytics response has unexpected Message-ID" unless evidence.message_id == id
154
+ evidence
155
+ end
156
+ end
157
+
158
+ private
159
+
160
+ def request(path, body = nil)
161
+ uri = URI("https://api.cloudflare.com/client/v4#{path}")
162
+ http = Net::HTTP.new(uri.host, uri.port)
163
+ http.use_ssl = true
164
+ http.open_timeout = http.read_timeout = http.write_timeout = REQUEST_TIMEOUT
165
+ http.max_retries = 0
166
+ request = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
167
+ request["Authorization"] = "Bearer #{@token}"
168
+ request["Content-Type"] = "application/json"
169
+ request.body = JSON.generate(body) if body
170
+ payload = +""
171
+ Timeout.timeout(REQUEST_TIMEOUT) do
172
+ http.request(request) do |response|
173
+ raise Error, "Routing analytics HTTP request failed" unless response.is_a?(Net::HTTPSuccess)
174
+ response.read_body do |chunk|
175
+ raise Error, "Routing analytics response is too large" if payload.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
176
+ payload << chunk
177
+ end
178
+ end
179
+ end
180
+ data = JSON.parse(payload)
181
+ unless data.is_a?(Hash) && data["success"] != false && (data["errors"].nil? || data["errors"] == [])
182
+ raise Error, "Routing analytics API rejected query"
183
+ end
184
+ data
185
+ rescue JSON::ParserError, KeyError, TypeError
186
+ raise Error, "Routing analytics API response is invalid", cause: nil
187
+ rescue Timeout::Error
188
+ raise Error, "Routing analytics HTTP deadline exceeded", cause: nil
189
+ rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError,
190
+ Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
191
+ raise Error, "Routing analytics HTTP request failed", cause: nil
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end