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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -0,0 +1,58 @@
1
+ require "json"
2
+ require "cloudflare/email/signing"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # Authenticated SMTP routing metadata, kept separate from sender-supplied MIME.
7
+ # v2 encodes exact {"from": string, "to": string} JSON as unpadded base64url.
8
+ module Envelope
9
+ METADATA_KEY = "cloudflare_email_envelope".freeze
10
+ MAX_ENCODED_BYTES = 1024
11
+ LOCAL_PART = /\A[A-Za-z0-9.!#$%&'*+\/=\?^_`{|}~-]+\z/
12
+ DOMAIN_LABEL = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
13
+
14
+ def self.valid_address?(address, allow_empty: false)
15
+ return false unless address.is_a?(String) && address.ascii_only?
16
+ return true if allow_empty && address.empty?
17
+ return false if address.bytesize > 254
18
+ parts = address.split("@", -1)
19
+ return false unless parts.size == 2
20
+ local, domain = parts
21
+ local.bytesize <= 64 && LOCAL_PART.match?(local) &&
22
+ !local.start_with?(".") && !local.end_with?(".") && !local.include?("..") &&
23
+ domain.split(".", -1).all? { |label| DOMAIN_LABEL.match?(label) }
24
+ end
25
+
26
+ def self.valid?(value)
27
+ value.is_a?(Hash) && value.size == 2 && value.key?("from") && value.key?("to") &&
28
+ valid_address?(value["from"], allow_empty: true) && valid_address?(value["to"])
29
+ end
30
+
31
+ def self.encode(from:, to:)
32
+ value = { "from" => from, "to" => to }
33
+ raise ArgumentError, "invalid SMTP envelope" unless valid?(value)
34
+ Signing.base64url_encode(JSON.generate(value))
35
+ end
36
+
37
+ def self.decode(encoded)
38
+ return nil unless encoded.is_a?(String) && encoded.bytesize.between?(1, MAX_ENCODED_BYTES)
39
+ return nil unless /\A[A-Za-z0-9_-]+\z/.match?(encoded)
40
+ decoded = Signing.base64url_decode(encoded)
41
+ return nil unless Signing.base64url_encode(decoded) == encoded
42
+ value = JSON.parse(decoded)
43
+ valid?(value) ? value : nil
44
+ rescue JSON::ParserError, ArgumentError
45
+ nil
46
+ end
47
+
48
+ # Only metadata written after v2/v3 verification is trusted. MIME headers
49
+ # (including X-CF-* headers) never participate in this lookup.
50
+ def self.for(inbound_email)
51
+ metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
52
+ return nil unless metadata.is_a?(Hash) && [2, 3].include?(metadata["version"])
53
+ value = metadata.reject { |key, _| key == "version" }
54
+ valid?(value) ? value : nil
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,95 @@
1
+ require "base64"
2
+ require "cloudflare/email/client"
3
+ require "cloudflare/email/delivery_event"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ # Short-poll a dedicated Email Sending event queue from Ruby/Rails.
8
+ # Each message is acknowledged only after the caller's block succeeds.
9
+ # Handler/parse/ack failures propagate; unacknowledged leases expire and
10
+ # are redelivered according to the queue's retry/dead-letter policy.
11
+ class EventConsumer < Client
12
+ # Up to 100 queue messages, including encoded payloads and metadata.
13
+ DEFAULT_MAX_RESPONSE_BYTES = 32 * 1_048_576
14
+
15
+ def initialize(queue_id:, domains: nil, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, **options)
16
+ raise ConfigurationError, "queue_id is required" unless queue_id.to_s.match?(/\A[a-zA-Z0-9_-]+\z/)
17
+ super(**options.merge(retries: 0, retry_ambiguous: false, max_response_bytes: max_response_bytes))
18
+ @queue_id = queue_id
19
+ @domains = domains && Array(domains).map(&:downcase)
20
+ end
21
+
22
+ def poll(batch_size: 5, visibility_timeout_ms: 300_000)
23
+ raise ArgumentError, "a delivery event handler block is required" unless block_given?
24
+ unless batch_size.is_a?(Integer) && (1..100).cover?(batch_size)
25
+ raise ArgumentError, "batch_size must be between 1 and 100"
26
+ end
27
+ unless visibility_timeout_ms.is_a?(Integer) && (1_000..43_200_000).cover?(visibility_timeout_ms)
28
+ raise ArgumentError, "visibility_timeout_ms must be between 1000 and 43200000"
29
+ end
30
+
31
+ response = request(:post, queue_path("pull"), {
32
+ batch_size: batch_size, visibility_timeout_ms: visibility_timeout_ms,
33
+ })
34
+ messages = response.result["messages"]
35
+ raise Error, "invalid queue pull response: messages must be an array" unless messages.is_a?(Array)
36
+
37
+ messages.each do |message|
38
+ unless message.is_a?(Hash) && message["lease_id"].is_a?(String) && !message["lease_id"].empty?
39
+ raise ValidationError, "queue message is missing its lease_id"
40
+ end
41
+ event = decode_event(message)
42
+ unless event.account_id == account_id && (!@domains || @domains.include?(event.domain.to_s.downcase))
43
+ raise ValidationError, "Email Sending event does not match configured account/domain"
44
+ end
45
+ instrument("cloudflare_email.delivery_event", event_id: event.event_id,
46
+ message_id: event.message_id, status: event.status) do
47
+ yield event
48
+ end
49
+ ack = request(:post, queue_path("ack"), { acks: [{ lease_id: message["lease_id"] }], retries: [] })
50
+ unless ack.result["ackCount"] == 1
51
+ raise Error.new("queue did not acknowledge the message; it may be redelivered", response: ack.raw, status: ack.status)
52
+ end
53
+ end
54
+ messages.length
55
+ end
56
+
57
+ private
58
+
59
+ def response_byte_limit(uri)
60
+ # ACKs contain counts, never a queue batch. Keep their smaller budget
61
+ # even when a pull needs room for 100 encoded event payloads.
62
+ uri.path.end_with?("/messages/ack") ? [@max_response_bytes, Client::DEFAULT_MAX_RESPONSE_BYTES].min : @max_response_bytes
63
+ end
64
+
65
+ def queue_path(action)
66
+ "/accounts/#{account_id}/queues/#{@queue_id}/messages/#{action}"
67
+ end
68
+
69
+ def decode_event(message)
70
+ body = message["body"]
71
+ metadata = message["metadata"]
72
+ content_type = metadata.is_a?(Hash) ? metadata["CF-Content-Type"] : nil
73
+ case content_type
74
+ when "json"
75
+ # Email Sending subscriptions return plain JSON strings from HTTP
76
+ # pull. Also accept the base64 JSON transport used by other producers.
77
+ begin
78
+ body = JSON.parse(body)
79
+ rescue JSON::ParserError
80
+ body = JSON.parse(Base64.strict_decode64(body))
81
+ end
82
+ when "bytes"
83
+ body = JSON.parse(Base64.strict_decode64(body))
84
+ when "text"
85
+ body = JSON.parse(body)
86
+ else
87
+ raise ValidationError, "unsupported queue content type; use json, bytes, or text"
88
+ end
89
+ DeliveryEvent.new(body)
90
+ rescue JSON::ParserError, ArgumentError, TypeError
91
+ raise ValidationError, "queue body must contain a valid JSON Email Sending event", cause: nil
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,115 @@
1
+ require "digest"
2
+ require "cloudflare/email/verification"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ # Shared bounded verification for custom Rack/Rails ingestion endpoints.
7
+ # This API deliberately performs no domain lookup or business processing.
8
+ module Ingress
9
+ DEFAULT_MAX_EMAIL_BYTES = 25 * 1024 * 1024
10
+ Result = Struct.new(:status, :message, :bytes, keyword_init: true)
11
+
12
+ class VerifiedMessage
13
+ attr_reader :body, :envelope, :provider_metadata, :storage_metadata, :message_checksum
14
+
15
+ def initialize(body:, envelope:, provider_metadata:, version:)
16
+ @body = body.b.freeze
17
+ @envelope = Ingress.deep_freeze(envelope)
18
+ @provider_metadata = Ingress.deep_freeze(provider_metadata)
19
+ @storage_metadata = {Envelope::METADATA_KEY => envelope.merge("version" => version.to_i)}
20
+ if provider_metadata
21
+ @storage_metadata[ProviderMetadata::METADATA_KEY] = provider_metadata.merge("signature_version" => 3)
22
+ end
23
+ Ingress.deep_freeze(@storage_metadata)
24
+ identity = if version == "3"
25
+ "v3\0#{JSON.generate([envelope.fetch('from'), envelope.fetch('to'), ProviderMetadata.canonical(provider_metadata)])}\0".b
26
+ else
27
+ "v2\0#{envelope.fetch('to')}\0".b
28
+ end
29
+ @message_checksum = Digest::SHA256.hexdigest(identity + @body).freeze
30
+ freeze
31
+ end
32
+ private_class_method :new
33
+
34
+ # Call within Mailboxes.receive when using the optional registry so the
35
+ # framework records and memberships share the resolved tenant context.
36
+ # Custom endpoints may instead persist body/metadata in their own store.
37
+ def persist_action_mailbox!
38
+ ::ActionMailbox::InboundEmail.transaction do
39
+ inbound = ::ActionMailbox::InboundEmail.create_and_extract_message_id!(body,
40
+ message_checksum: message_checksum, message_id: stable_message_id)
41
+ if inbound
42
+ blob = inbound.raw_email.blob
43
+ blob.update!(metadata: blob.metadata.merge(storage_metadata))
44
+ end
45
+ inbound
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ # ActionMailbox's fallback includes Socket.gethostname. A replay on a
52
+ # different Rails host must use the same database identity, while the
53
+ # original MIME stays untouched. Preserve parseable provider IDs.
54
+ def stable_message_id
55
+ parsed = begin
56
+ ::Mail.from_source(body).message_id
57
+ rescue StandardError
58
+ nil
59
+ end
60
+ parsed || "#{message_checksum}@cloudflare-email.invalid"
61
+ end
62
+ end
63
+
64
+ def self.verify(secret:, headers:, body:, content_length: nil,
65
+ max_email_bytes: DEFAULT_MAX_EMAIL_BYTES, now: Time.now.to_i, window: Verification::DEFAULT_WINDOW)
66
+ unless max_email_bytes.is_a?(Integer) && max_email_bytes.positive?
67
+ raise ArgumentError, "max_email_bytes must be a positive integer"
68
+ end
69
+ values = %w[Timestamp Signature Signature-Version Envelope Metadata].to_h do |name|
70
+ key = "X-CF-Email-#{name}"
71
+ value = headers[key] || headers["HTTP_#{key.upcase.tr('-', '_')}"]
72
+ [name, value.is_a?(String) ? value.dup.freeze : value]
73
+ end
74
+ options = {secret: secret, timestamp: values["Timestamp"], signature: values["Signature"],
75
+ version: values["Signature-Version"], envelope: values["Envelope"], metadata: values["Metadata"],
76
+ now: now, window: window}
77
+ status = Verification.verify_headers(**options)
78
+ return Result.new(status: status).freeze unless status == :ok
79
+ if content_length
80
+ length = Integer(content_length.to_s, exception: false)
81
+ return Result.new(status: :bad_signature).freeze unless length && length >= 0
82
+ return Result.new(status: :too_large).freeze if length > max_email_bytes
83
+ end
84
+ raw = if body.is_a?(String)
85
+ return Result.new(status: :too_large).freeze if body.bytesize > max_email_bytes
86
+ body.dup.b
87
+ elsif body.respond_to?(:read)
88
+ buffer = +"".b
89
+ while buffer.bytesize <= max_email_bytes
90
+ chunk = body.read(max_email_bytes + 1 - buffer.bytesize)
91
+ break if chunk.nil? || chunk.empty?
92
+ buffer << chunk.b
93
+ end
94
+ buffer
95
+ else
96
+ raise ArgumentError, "body must be a String or readable IO"
97
+ end
98
+ return Result.new(status: :too_large).freeze if raw.bytesize > max_email_bytes
99
+ status = Verification.verify(body: raw, **options)
100
+ return Result.new(status: status, bytes: raw.bytesize).freeze unless status == :ok
101
+ message = VerifiedMessage.send(:new, body: raw, version: values["Signature-Version"],
102
+ envelope: Envelope.decode(values["Envelope"]), provider_metadata: ProviderMetadata.decode(values["Metadata"]))
103
+ Result.new(status: :ok, message: message, bytes: raw.bytesize).freeze
104
+ end
105
+
106
+ def self.deep_freeze(value)
107
+ case value
108
+ when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
109
+ when Array then value.each { |item| deep_freeze(item) }
110
+ end
111
+ value.freeze
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,58 @@
1
+ require "active_record"
2
+ require "cloudflare/email/error"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Mailboxes
7
+ class Unavailable < Cloudflare::Email::Error; end
8
+
9
+ class << self
10
+ def configure(directory_base: ::ActiveRecord::Base, client_resolver: nil)
11
+ if const_defined?(:ReceivingDomain, false)
12
+ raise ConfigurationError, "configure mailboxes before loading mailbox models"
13
+ end
14
+ unless directory_base.is_a?(Class) && directory_base <= ::ActiveRecord::Base
15
+ raise ConfigurationError, "directory_base must be an ActiveRecord base class"
16
+ end
17
+ if client_resolver && !client_resolver.respond_to?(:call)
18
+ raise ConfigurationError, "client_resolver must be callable"
19
+ end
20
+ @directory_base = directory_base
21
+ @client_resolver = client_resolver
22
+ end
23
+
24
+ def directory_base = @directory_base || ::ActiveRecord::Base
25
+
26
+ def delivery_handler = rails_setting(:outbox_delivery_handler)
27
+ def recipient_handler = rails_setting(:outbox_recipient_handler)
28
+
29
+ def client_for(tenant_key, account_id)
30
+ client = if @client_resolver
31
+ @client_resolver.call(tenant_key, account_id)
32
+ else
33
+ require "cloudflare/email/credentials"
34
+ unless Credentials.account_id == account_id
35
+ raise ConfigurationError, "configure a tenant/account client_resolver for this sending account"
36
+ end
37
+ Client.new(account_id: account_id, api_token: Credentials.api_token, retry_ambiguous: false)
38
+ end
39
+ unless client.account_id == account_id && client.retry_ambiguous == false
40
+ raise ConfigurationError, "mailbox client must match account and disable ambiguous retries"
41
+ end
42
+ client
43
+ end
44
+
45
+ def enabled? = @enabled == true
46
+ def enable! = @enabled = true
47
+
48
+ private
49
+
50
+ def rails_setting(name)
51
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
52
+ ::Rails.application.config.x.cloudflare_email.public_send(name)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,112 @@
1
+ require "cloudflare/email/active_record/delivery_events"
2
+ require "cloudflare/email/mailboxes/shared_event_receipt"
3
+ require "cloudflare/email/mailboxes/provider_correlation"
4
+
5
+ module Cloudflare
6
+ module Email
7
+ module Mailboxes
8
+ # Queue ACK follows shared intake. Projection is separately replayable:
9
+ # a crash between tenant commit and shared completion simply replays the
10
+ # tenant's idempotent receipt. No distributed transaction is assumed.
11
+ class Events
12
+ class << self
13
+ def record(event)
14
+ outside_shared_transaction!
15
+ event = DeliveryEvent.new(event.raw)
16
+ receipt = SharedEventReceipt.create_or_find_by!(account_id: event.account_id, event_id: event.event_id) do |row|
17
+ row.message_id = MessageId.normalize(event.message_id)
18
+ row.recipient = normalize_recipient(event.recipient)
19
+ row.payload_json = JSON.generate(event.raw)
20
+ row.state = "pending"
21
+ end
22
+ raise ValidationError, "event ID already recorded with a different payload" unless receipt.event.raw == event.raw
23
+ receipt
24
+ end
25
+
26
+ # Call after provider acceptance has committed in the tenant database.
27
+ # Duplicate registrations are harmless; collisions remain visible and
28
+ # deliberately prevent routing rather than selecting an arbitrary tenant.
29
+ def register(delivery, tenant_key: Tenancy.require_context!)
30
+ assert_context!(tenant_key)
31
+ outside_shared_transaction!
32
+ raise ArgumentError, "registration must follow the tenant commit" if delivery.class.connection.transaction_open?
33
+ delivery.reload
34
+ unless delivery.persisted? && %w[accepted partial].include?(delivery.state) && !delivery.provider_message_id.to_s.empty?
35
+ raise ArgumentError, "expected a committed accepted delivery with a provider message ID"
36
+ end
37
+ unless OutboundMessage.where(tenant_key: tenant_key, outbound_delivery_id: delivery.id).exists?
38
+ raise ArgumentError, "delivery does not belong to a mailbox in the current tenant"
39
+ end
40
+ delivery.outbound_recipients.map do |recipient|
41
+ ProviderCorrelation.create_or_find_by!(account_id: delivery.account_id,
42
+ message_id: MessageId.normalize(delivery.provider_message_id), recipient: normalize_recipient(recipient.recipient),
43
+ tenant_key: tenant_key.to_s, outbound_delivery_id: delivery.id)
44
+ end
45
+ end
46
+
47
+ def apply(receipt, &on_change)
48
+ outside_shared_transaction!
49
+ raise ArgumentError, "expected a persisted shared receipt" unless receipt.is_a?(SharedEventReceipt) && receipt.persisted?
50
+ receipt.reload
51
+ return receipt if receipt.state == "applied"
52
+
53
+ candidates = ProviderCorrelation.where(account_id: receipt.account_id,
54
+ message_id: receipt.message_id, recipient: receipt.recipient).limit(2).to_a
55
+ outcome = :unmatched
56
+ if candidates.length == 1
57
+ correlation = candidates.first
58
+ outcome = Tenancy.with(correlation.tenant_key) do
59
+ project(receipt.event, correlation, &on_change)
60
+ end
61
+ end
62
+ # Never overwrite another worker's completed projection.
63
+ SharedEventReceipt.where(id: receipt.id, state: %w[pending unmatched]).update_all(
64
+ state: outcome.to_s, applied_at: outcome == :applied ? Time.now.utc : nil, updated_at: Time.now.utc)
65
+ receipt.reload
66
+ end
67
+
68
+ # A bounded page, not an unbounded find_each scan. Use the last scanned
69
+ # ID as after_id for the next page, and restart at zero on the next pass.
70
+ def replay(limit: 100, after_id: 0, account_id: nil, &on_change)
71
+ raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
72
+ raise ArgumentError, "after_id must be nonnegative" unless after_id.is_a?(Integer) && after_id >= 0
73
+ scope = SharedEventReceipt.where(state: %w[pending unmatched]).where("id > ?", after_id)
74
+ scope = scope.where(account_id: account_id) if account_id
75
+ scope.order(:id).limit(limit).to_a.each { |receipt| apply(receipt, &on_change) }
76
+ end
77
+
78
+ private
79
+
80
+ def outside_shared_transaction!
81
+ if SharedEventReceipt.connection.transaction_open?
82
+ raise ArgumentError, "shared event processing must run outside an existing database transaction"
83
+ end
84
+ end
85
+
86
+ def normalize_recipient(value)
87
+ ActiveRecord::Outbox.normalize_recipient(value)
88
+ end
89
+
90
+ def assert_context!(key)
91
+ raise ConfigurationError, "delivery belongs to another tenant" unless Tenancy.require_context! == key
92
+ end
93
+
94
+ def project(event, correlation, &on_change)
95
+ assert_context!(correlation.tenant_key)
96
+ return :unmatched unless OutboundMessage.where(tenant_key: correlation.tenant_key,
97
+ outbound_delivery_id: correlation.outbound_delivery_id).exists?
98
+ delivery = ActiveRecord::OutboundDelivery.find_by(id: correlation.outbound_delivery_id,
99
+ account_id: event.account_id, provider_message_id: MessageId.normalize(event.message_id), state: %w[accepted partial])
100
+ return :unmatched unless delivery && delivery.outbound_recipients.exists?(recipient: normalize_recipient(event.recipient))
101
+ return :unmatched unless ReceivingDomain.active.where(tenant_key: correlation.tenant_key,
102
+ account_id: event.account_id, domain: delivery.from_address.rpartition("@").last.downcase).exists?
103
+ # DeliveryEvents also refuses collisions within the tenant. Its
104
+ # transactional receipt protects callback writes against redelivery.
105
+ receipt = ActiveRecord::DeliveryEvents.record(event, &on_change)
106
+ receipt.state == "applied" ? :applied : :unmatched
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,14 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Mailboxes
4
+ # ActionMailbox normally deletes processed mail after its retention window.
5
+ # A mailbox membership owns the raw source until explicit application purge.
6
+ module InboundRetention
7
+ def incinerate
8
+ return if Mailboxes.enabled? && Message.where(inbound_email_id: id).exists?
9
+ super
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,34 @@
1
+ require "active_job"
2
+
3
+ module Cloudflare
4
+ module Email
5
+ module Mailboxes
6
+ class SendJob < ::ActiveJob::Base
7
+ queue_as :mailers
8
+
9
+ def perform(tenant_key, mailbox_id, operation_key)
10
+ Mailboxes.for_tenant(tenant_key) { |session| session.deliver(mailbox_id, operation_key: operation_key) }
11
+ end
12
+ end
13
+
14
+ class RecoverJob < ::ActiveJob::Base
15
+ queue_as :mailers
16
+
17
+ def perform(tenant_key, after_id = 0)
18
+ cursor = Mailboxes.for_tenant(tenant_key) { |session| session.recover(after_id: after_id) }
19
+ self.class.perform_later(tenant_key, cursor) if cursor
20
+ end
21
+ end
22
+
23
+ class ReplayEventsJob < ::ActiveJob::Base
24
+ queue_as :mailers
25
+
26
+ def perform(after_id = 0)
27
+ handler = Mailboxes.recipient_handler
28
+ page = Events.replay(after_id: after_id, &(handler.method(:call) if handler.respond_to?(:call)))
29
+ self.class.perform_later(page.last.id) if page.length == 100
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,165 @@
1
+ require "active_record"
2
+ require "cloudflare/email/active_record/base"
3
+
4
+ module Cloudflare
5
+ module Email
6
+ module Mailboxes
7
+ # Domains are an administrator-maintained receiving directory. An active
8
+ # record is an assertion by the application, not DNS ownership verification.
9
+ class ReceivingDomain < Mailboxes.directory_base
10
+ self.table_name = "cloudflare_email_receiving_domains"
11
+ STATES = %w[pending active suspended].freeze
12
+ attr_readonly :domain, :tenant_key, :account_id
13
+ before_validation(on: :create) { self.domain = domain.to_s.strip.downcase }
14
+ validates :tenant_key, :account_id, presence: true
15
+ validates :domain, presence: true, uniqueness: true,
16
+ length: { maximum: 253 },
17
+ format: { with: /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\z/ }
18
+ validates :state, inclusion: { in: STATES }
19
+ validate do
20
+ errors.add(:domain, "has an oversized label") if domain.to_s.split(".").any? { |label| label.length > 63 }
21
+ end
22
+ scope :active, -> { where(state: "active") }
23
+ end
24
+
25
+ module TenantIdentity
26
+ extend ActiveSupport::Concern
27
+ included do
28
+ attr_readonly :tenant_key
29
+ validates :tenant_key, presence: true
30
+ before_validation :assign_tenant_identity
31
+ validate :validate_tenant_identity
32
+ before_destroy :assert_tenant_identity!
33
+ end
34
+
35
+ private
36
+
37
+ def assign_tenant_identity
38
+ self.tenant_key ||= Tenancy.current_key
39
+ end
40
+
41
+ def validate_tenant_identity
42
+ return unless Tenancy.enabled? || Tenancy.current_key
43
+ Tenancy.require_context!
44
+ errors.add(:tenant_key, "does not match the current tenant") unless tenant_key == Tenancy.current_key
45
+ end
46
+
47
+ def assert_tenant_identity!
48
+ return unless Tenancy.enabled? || Tenancy.current_key
49
+ Tenancy.require_context!
50
+ raise ArgumentError, "record belongs to another tenant" unless tenant_key == Tenancy.current_key
51
+ end
52
+
53
+ def validate_parent_tenant(parent, attribute)
54
+ errors.add(attribute, "belongs to another tenant") if parent && parent.tenant_key != tenant_key
55
+ end
56
+ end
57
+
58
+ class Mailbox < Cloudflare::Email::ActiveRecord::Base
59
+ include TenantIdentity
60
+ self.table_name = "cloudflare_email_mailboxes"
61
+ has_many :addresses, class_name: "Cloudflare::Email::Mailboxes::Address", dependent: :restrict_with_exception
62
+ has_many :messages, class_name: "Cloudflare::Email::Mailboxes::Message", dependent: :restrict_with_exception
63
+ has_many :outbound_messages, class_name: "Cloudflare::Email::Mailboxes::OutboundMessage", dependent: :restrict_with_exception
64
+ validates :name, presence: true, length: { maximum: 255 }
65
+ validates :state, inclusion: { in: %w[active suspended] }
66
+ scope :active, -> { where(state: "active") }
67
+ end
68
+
69
+ class Address < Cloudflare::Email::ActiveRecord::Base
70
+ include TenantIdentity
71
+ self.table_name = "cloudflare_email_addresses"
72
+ belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
73
+ attr_readonly :mailbox_id, :receiving_domain_id, :local_part, :domain, :address
74
+ before_validation :normalize_address, on: :create
75
+ validates :mailbox, :receiving_domain_id, presence: true
76
+ validates :local_part, length: { in: 1..64 },
77
+ format: { with: /\A[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+(?:\.[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+)*\z/ }
78
+ validates :address, uniqueness: true, length: { maximum: 254 }
79
+ validates :state, inclusion: { in: %w[pending active suspended] }
80
+ validate :validate_directory
81
+ validate :validate_catch_all
82
+ validate { validate_parent_tenant(mailbox, :mailbox) }
83
+ scope :active, -> { where(state: "active") }
84
+
85
+ def receiving_domain
86
+ ReceivingDomain.find_by(id: receiving_domain_id)
87
+ end
88
+
89
+ private
90
+
91
+ def normalize_address
92
+ self.local_part = local_part.to_s.strip.downcase
93
+ self.domain = domain.to_s.strip.downcase
94
+ self.address = "#{local_part}@#{domain}"
95
+ end
96
+
97
+ def validate_directory
98
+ registered = receiving_domain
99
+ unless registered && registered.domain == domain && registered.tenant_key == tenant_key
100
+ errors.add(:receiving_domain_id, "must match this tenant and domain")
101
+ return
102
+ end
103
+ if new_record? && registered.state != "active"
104
+ errors.add(:receiving_domain_id, "must be active before creating addresses")
105
+ end
106
+ end
107
+
108
+ def validate_catch_all
109
+ # Existing exact-address installations need not migrate until they
110
+ # opt into catch-all receiving.
111
+ return unless has_attribute?(:catch_all) && self[:catch_all]
112
+ errors.add(:catch_all_evidence, "is required") if self[:catch_all_evidence].to_s.strip.empty?
113
+ if will_save_change_to_catch_all? && state != "active"
114
+ errors.add(:catch_all, "requires an active address")
115
+ end
116
+ return unless state == "active"
117
+ errors.add(:catch_all, "requires an active mailbox") unless mailbox&.state == "active"
118
+ errors.add(:catch_all, "requires an active receiving domain") unless receiving_domain&.state == "active"
119
+ if self.class.where(receiving_domain_id: receiving_domain_id, catch_all: true, state: "active").where.not(id: id).exists?
120
+ errors.add(:catch_all, "already exists for this receiving domain")
121
+ end
122
+ end
123
+ end
124
+
125
+ class Message < Cloudflare::Email::ActiveRecord::Base
126
+ include TenantIdentity
127
+ self.table_name = "cloudflare_email_mailbox_messages"
128
+ belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
129
+ attr_readonly :mailbox_id, :inbound_email_id, :recipient
130
+ validates :mailbox, :inbound_email_id, :recipient, presence: true
131
+ # The unique database index also supports create_or_find_by! retries.
132
+ validate { validate_parent_tenant(mailbox, :mailbox) }
133
+ scope :unread, -> { where(read_at: nil) }
134
+ scope :inbox, -> { where(archived_at: nil) }
135
+
136
+ def mark_read!
137
+ update!(read_at: Time.current)
138
+ end
139
+
140
+ def mark_unread!
141
+ update!(read_at: nil)
142
+ end
143
+
144
+ def archive!
145
+ update!(archived_at: Time.current)
146
+ end
147
+
148
+ def unarchive!
149
+ update!(archived_at: nil)
150
+ end
151
+ end
152
+
153
+ class OutboundMessage < Cloudflare::Email::ActiveRecord::Base
154
+ include TenantIdentity
155
+ self.table_name = "cloudflare_email_mailbox_outbound_messages"
156
+ belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
157
+ belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
158
+ attr_readonly :mailbox_id, :outbound_delivery_id
159
+ validates :mailbox, :outbound_delivery, presence: true
160
+ # Enforce uniqueness in the database so idempotent link creation works.
161
+ validate { validate_parent_tenant(mailbox, :mailbox) }
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,11 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Mailboxes
4
+ # Shared routing evidence, never inferred from a sender or event domain.
5
+ class ProviderCorrelation < Mailboxes.directory_base
6
+ self.table_name = "cloudflare_email_provider_correlations"
7
+ attr_readonly :account_id, :message_id, :recipient, :tenant_key, :outbound_delivery_id
8
+ end
9
+ end
10
+ end
11
+ end