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,372 @@
1
+ require "digest"
2
+ require "cloudflare/email/envelope"
3
+
4
+ module Cloudflare
5
+ module Email
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
+
17
+ class << self
18
+ # Call after host authorization. The directory is a control-plane API,
19
+ # not a public endpoint accepting arbitrary customer domain claims.
20
+ def register_domain(domain:, tenant_key:, account_id:)
21
+ Tenancy.normalize_key(tenant_key)
22
+ ReceivingDomain.create!(domain: domain, tenant_key: tenant_key, account_id: account_id)
23
+ end
24
+
25
+ def activate_domain!(id, evidence:, sending_enabled: false)
26
+ raise ArgumentError, "verification evidence is required" if evidence.to_s.strip.empty?
27
+ domain = ReceivingDomain.find(id)
28
+ domain.update!(state: "active", provisioning_evidence: evidence,
29
+ verified_at: Time.now.utc, sending_enabled: sending_enabled)
30
+ domain
31
+ end
32
+
33
+ def for_tenant(key)
34
+ raise ArgumentError, "a block is required" unless block_given?
35
+ Tenancy.with(key) { yield Session.new(key) }
36
+ end
37
+
38
+ # Only call after verifying the complete ingress HMAC. Resolve before
39
+ # ActionMailbox or ActiveStorage accesses a tenant connection.
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 }
56
+ end
57
+ end
58
+
59
+ def canonical_address(value)
60
+ unless Envelope.valid_address?(value)
61
+ raise ValidationError, "mailbox address must be an ASCII dot-atom address"
62
+ end
63
+ value.downcase
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
76
+ end
77
+
78
+ class Session
79
+ attr_reader :tenant_key
80
+
81
+ def initialize(tenant_key)
82
+ @tenant_key = tenant_key
83
+ end
84
+
85
+ def mailboxes
86
+ context!
87
+ Mailbox.where(tenant_key: tenant_key)
88
+ end
89
+
90
+ def create(name:, address:, owner_ref: nil)
91
+ context!
92
+ Mailbox.transaction do
93
+ mailbox = Mailbox.create!(tenant_key: tenant_key, name: name, owner_ref: owner_ref)
94
+ add_address(mailbox.id, address: address)
95
+ mailbox
96
+ end
97
+ end
98
+
99
+ def add_address(mailbox_id, address:)
100
+ mailbox = active_mailbox!(mailbox_id)
101
+ canonical = Mailboxes.canonical_address(address)
102
+ local, domain = canonical.split("@", 2)
103
+ directory = ReceivingDomain.find_by!(domain: domain, tenant_key: tenant_key, state: "active")
104
+ Address.create!(tenant_key: tenant_key, mailbox_id: mailbox.id,
105
+ receiving_domain_id: directory.id, local_part: local, domain: domain,
106
+ address: canonical, state: "pending")
107
+ end
108
+
109
+ def activate_address!(address_id, evidence:)
110
+ context!
111
+ raise ArgumentError, "route verification evidence is required" if evidence.to_s.strip.empty?
112
+ address = Address.where(tenant_key: tenant_key).find(address_id)
113
+ active_mailbox!(address.mailbox_id)
114
+ active_domain!(address)
115
+ address.update!(state: "active", provisioning_evidence: evidence)
116
+ address
117
+ end
118
+
119
+ def provision_address!(address_id, provisioner:, worker_name:)
120
+ context!
121
+ raise ArgumentError, "provision outside a database transaction" if Address.connection.transaction_open?
122
+ address = Address.where(tenant_key: tenant_key).find(address_id)
123
+ active_mailbox!(address.mailbox_id)
124
+ active_domain!(address)
125
+ provisioner.provision(address: address.address, worker_name: worker_name)
126
+ # Failed requests leave the address pending; the existing provisioner
127
+ # upserts a rule, making a deliberate retry safe.
128
+ activate_address!(address.id, evidence: "Cloudflare address rule provisioned for Worker #{worker_name}")
129
+ end
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
+
154
+ def suspend(mailbox_id)
155
+ mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "suspended") }
156
+ end
157
+
158
+ def resume(mailbox_id)
159
+ mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "active") }
160
+ end
161
+
162
+ def messages(mailbox_id)
163
+ mailbox = mailboxes.find(mailbox_id)
164
+ Message.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
165
+ end
166
+
167
+ def addresses(mailbox_id)
168
+ mailbox = mailboxes.find(mailbox_id)
169
+ Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
170
+ end
171
+
172
+ def suspend_address(mailbox_id, address_id)
173
+ addresses(mailbox_id).find(address_id).tap { |address| address.update!(state: "suspended") }
174
+ end
175
+
176
+ def outbound_messages(mailbox_id)
177
+ mailbox = mailboxes.find(mailbox_id)
178
+ OutboundMessage.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
179
+ end
180
+
181
+ def inbound_email(mailbox_id, message_id)
182
+ message = messages(mailbox_id).find(message_id)
183
+ ensure_storage_connection!
184
+ ::ActionMailbox::InboundEmail.find(message.inbound_email_id)
185
+ end
186
+
187
+ # Explicit permanent removal. Archive is the reversible default.
188
+ # Other mailbox memberships retain their shared raw source.
189
+ def purge_message(mailbox_id, message_id)
190
+ context!
191
+ ensure_storage_connection!
192
+ Message.transaction do
193
+ message = messages(mailbox_id).find(message_id)
194
+ inbound_id = message.inbound_email_id
195
+ message.destroy!
196
+ unless Message.where(inbound_email_id: inbound_id).exists?
197
+ ::ActionMailbox::InboundEmail.find_by(id: inbound_id)&.destroy!
198
+ end
199
+ end
200
+ end
201
+
202
+ def mark_read(mailbox_id, message_id, read: true)
203
+ messages(mailbox_id).find(message_id).update!(read_at: read ? Time.now.utc : nil)
204
+ end
205
+
206
+ def archive(mailbox_id, message_id, archived: true)
207
+ messages(mailbox_id).find(message_id).update!(archived_at: archived ? Time.now.utc : nil)
208
+ end
209
+
210
+ def prepare(mailbox_id, operation_key:, mail:)
211
+ context!
212
+ raise ArgumentError, "operation_key is required" unless operation_key.is_a?(String) && !operation_key.strip.empty?
213
+ mail = mail.message if mail.respond_to?(:message) && !mail.respond_to?(:encoded)
214
+ raise ValidationError, "mail must be a rendered Mail message" unless mail.respond_to?(:encoded)
215
+ mailbox = active_mailbox!(mailbox_id)
216
+ from = Array(mail.from)
217
+ raise ValidationError, "mail must have one mailbox From address" unless from.length == 1
218
+ sender = Array(mail.sender)
219
+ address = authorized_sender!(mailbox, from.first)
220
+ envelope_from = mail.respond_to?(:smtp_envelope_from) ? mail.smtp_envelope_from : from.first
221
+ unless Mailboxes.canonical_address(envelope_from) == address.address &&
222
+ sender.all? { |value| Mailboxes.canonical_address(value) == address.address }
223
+ raise ValidationError, "MIME and envelope sender must match the mailbox address"
224
+ end
225
+ domain = active_domain!(address)
226
+ raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled
227
+ key = "mailbox:#{Digest::SHA256.hexdigest(JSON.generate([tenant_key, mailbox.id, operation_key]))}"
228
+ Mailbox.transaction do
229
+ delivery = ActiveRecord::Outbox.prepare_mail(account_id: domain.account_id, operation_key: key, mail: mail)
230
+ OutboundMessage.create_or_find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id,
231
+ outbound_delivery_id: delivery.id)
232
+ delivery
233
+ end
234
+ end
235
+
236
+ # Enqueue only after the outer transaction commits. No mail or model
237
+ # GlobalIDs are serialized into this job, just stable identities.
238
+ def enqueue(mailbox_id, operation_key:)
239
+ context!
240
+ raise ArgumentError, "enqueue after database commit" if Mailbox.connection.transaction_open?
241
+ linked_delivery!(mailbox_id, operation_key)
242
+ SendJob.perform_later(tenant_key, mailbox_id, operation_key)
243
+ end
244
+
245
+ def deliver(mailbox_id, operation_key:)
246
+ mailbox = active_mailbox!(mailbox_id)
247
+ delivery = linked_delivery!(mailbox.id, operation_key)
248
+ address = authorized_sender!(mailbox, delivery.from_address)
249
+ domain = active_domain!(address)
250
+ raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled && domain.account_id == delivery.account_id
251
+ ActiveRecord::Outbox.deliver(delivery, client: Mailboxes.client_for(tenant_key, delivery.account_id))
252
+ handler = Mailboxes.delivery_handler
253
+ delivery.with_lock { handler.call(delivery) } if handler.respond_to?(:call)
254
+ Events.register(delivery, tenant_key: tenant_key) if %w[accepted partial].include?(delivery.state) && delivery.provider_message_id
255
+ delivery
256
+ end
257
+
258
+ def reconcile(mailbox_id, operation_key:, **evidence, &handler)
259
+ delivery = linked_delivery!(mailbox_id, operation_key)
260
+ raise ArgumentError, "reconcile outside an outer transaction" if Mailbox.connection.transaction_open?
261
+ ActiveRecord::Outbox.reconcile(delivery, **evidence, &handler)
262
+ Events.register(delivery, tenant_key: tenant_key) if %w[accepted partial].include?(delivery.state) && delivery.provider_message_id
263
+ delivery
264
+ end
265
+
266
+ # A bounded repair pass after an enqueue gap or cross-database commit
267
+ # gap. Does not automatically resend sending/unknown/rejected rows.
268
+ def recover(limit: 100, after_id: 0)
269
+ context!
270
+ raise ArgumentError, "recover outside a database transaction" if Mailbox.connection.transaction_open?
271
+ raise ArgumentError, "limit must be 1..1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
272
+ raise ArgumentError, "after_id must be nonnegative" unless after_id.is_a?(Integer) && after_id >= 0
273
+ links = OutboundMessage.where(tenant_key: tenant_key).where("id > ?", after_id).order(:id).limit(limit).to_a
274
+ links.each do |link|
275
+ delivery = ActiveRecord::OutboundDelivery.find(link.outbound_delivery_id)
276
+ if delivery.state == "prepared"
277
+ enqueue(link.mailbox_id, operation_key: delivery.operation_key) if mailboxes.find(link.mailbox_id).state == "active"
278
+ elsif %w[accepted partial].include?(delivery.state)
279
+ handler = Mailboxes.delivery_handler
280
+ delivery.with_lock { handler.call(delivery) } if handler.respond_to?(:call)
281
+ Events.register(delivery, tenant_key: tenant_key) if delivery.provider_message_id
282
+ end
283
+ end
284
+ links.last&.id
285
+ end
286
+
287
+ def receive(address, directory)
288
+ context!
289
+ ensure_storage_connection! if defined?(::ActionMailbox::InboundEmail)
290
+ Mailbox.transaction do
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,
294
+ inbound_email_id: inbound.id) { |row| row.recipient = address } if inbound
295
+ inbound
296
+ end
297
+ end
298
+
299
+ def with_recipient(address, directory)
300
+ context!
301
+ yield resolve_destination(address, directory)
302
+ end
303
+
304
+ private
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
+
326
+ def context!
327
+ raise ConfigurationError, "mailbox session used outside its tenant context" unless Tenancy.require_context! == tenant_key
328
+ end
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
+
341
+ def ensure_storage_connection!
342
+ unless defined?(::ActionMailbox::InboundEmail) && ::ActionMailbox::InboundEmail.connection_pool == Mailbox.connection_pool &&
343
+ ::ActiveStorage::Blob.connection_pool == Mailbox.connection_pool
344
+ raise ConfigurationError, "ActionMailbox, ActiveStorage and mailbox records must share the tenant connection"
345
+ end
346
+ end
347
+
348
+ def active_mailbox!(id)
349
+ context!
350
+ mailboxes.where(state: "active").find(id)
351
+ end
352
+
353
+ def active_domain!(address)
354
+ ReceivingDomain.find_by!(id: address.receiving_domain_id, domain: address.domain,
355
+ tenant_key: tenant_key, state: "active")
356
+ end
357
+
358
+ def authorized_sender!(mailbox, address)
359
+ Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id, state: "active")
360
+ .find_by!(address: Mailboxes.canonical_address(address))
361
+ end
362
+
363
+ def linked_delivery!(mailbox_id, operation_key)
364
+ mailbox = mailboxes.find(mailbox_id)
365
+ delivery = ActiveRecord::OutboundDelivery.find_by!(operation_key: operation_key)
366
+ OutboundMessage.find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id, outbound_delivery_id: delivery.id)
367
+ delivery
368
+ end
369
+ end
370
+ end
371
+ end
372
+ end
@@ -0,0 +1,14 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Mailboxes
4
+ class SharedEventReceipt < Mailboxes.directory_base
5
+ self.table_name = "cloudflare_email_shared_event_receipts"
6
+ attr_readonly :account_id, :event_id, :message_id, :recipient, :payload_json
7
+
8
+ def event
9
+ DeliveryEvent.new(JSON.parse(payload_json))
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,11 @@
1
+ # Explicit opt-in. Configure Tenancy and Mailboxes before loading this file.
2
+ require "cloudflare-email"
3
+ require "cloudflare/email/tenancy"
4
+ require "cloudflare/email/mailboxes/configuration"
5
+ require "cloudflare/email/active_record"
6
+ require "cloudflare/email/mailboxes/models"
7
+ require "cloudflare/email/mailboxes/events"
8
+ require "cloudflare/email/mailboxes/service"
9
+ require "cloudflare/email/mailboxes/jobs"
10
+
11
+ Cloudflare::Email::Mailboxes.enable!
@@ -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,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module Email
5
+ # Correlation only: a Message-ID does not authorize access to a thread.
6
+ module MessageId
7
+ def self.normalize(value)
8
+ value.to_s.strip.sub(/\A<([^<>]+)>\z/, '\1')
9
+ end
10
+ end
11
+ end
12
+ end
@@ -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