cloudflare-email 0.3.0 → 0.4.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +14 -4
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
  7. data/docs/custom-ingress.md +5 -1
  8. data/docs/features.md +1 -1
  9. data/docs/getting-started.md +4 -3
  10. data/docs/mailboxes.md +8 -1
  11. data/docs/routing-diagnostics.md +7 -0
  12. data/docs/troubleshooting.md +1 -1
  13. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  14. data/lib/cloudflare/email/active_record/base.rb +3 -53
  15. data/lib/cloudflare/email/engine.rb +1 -13
  16. data/lib/cloudflare/email/envelope.rb +2 -12
  17. data/lib/cloudflare/email/error.rb +5 -12
  18. data/lib/cloudflare/email/ingress.rb +19 -8
  19. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  20. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  21. data/lib/cloudflare/email/mailboxes/models.rb +8 -147
  22. data/lib/cloudflare/email/mailboxes/service.rb +7 -258
  23. data/lib/cloudflare/email/mailboxes.rb +1 -0
  24. data/lib/cloudflare/email/management/adapter.rb +3 -25
  25. data/lib/cloudflare/email/management/configuration.rb +4 -11
  26. data/lib/cloudflare/email/management/engine.rb +2 -0
  27. data/lib/cloudflare/email/tenancy.rb +2 -73
  28. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  29. data/lib/cloudflare/email/version.rb +1 -1
  30. data/lib/cloudflare-email.rb +6 -0
  31. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
  32. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  33. data/templates/deploy-to-cloudflare/README.md +21 -1
  34. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  35. data/templates/deploy-to-cloudflare/package.json +1 -0
  36. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  37. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  38. data/templates/worker/README.md +6 -0
  39. data/templates/worker/docs/domain-setup.md +198 -0
  40. data/templates/worker/package.json +1 -0
  41. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  42. data/templates/worker/test/subdomains.test.ts +51 -0
  43. metadata +22 -6
  44. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
  45. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
  46. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
  47. data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
  48. data/lib/cloudflare/email/management/management.css +0 -68
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8b7e8e8e9c8f102cf640de5aa577092947f829853daea59463263b8729ede21
4
- data.tar.gz: 7303add7cb750e309c338132e114ecd40340bf5e9ee39b6b03c3f28acd9887d1
3
+ metadata.gz: c65b29d22af39c25cb2affb776984bc9a9086bcad75c7388c2017692f61eb9ef
4
+ data.tar.gz: 2c0d1f15f4159e780257260af62cca8d414c7cc00d7c1c8b2677ac243cf81b04
5
5
  SHA512:
6
- metadata.gz: 77cbd0de1b51f621f834fa9493c31865cc0017e0f221885503ad6c891f783f39b3e0eaf07eaabb148a9e34c694bed9d8e4a0dfa27cd052e70e0d9e17164e9483
7
- data.tar.gz: 3e3ee8aa24120e3b264efd7328b18dc3c48ab044656c0e15d0f73a13d2fd18e06247ab7392bc72aba472d37bee1b080a77113e492d4dcbe8c6945e2c5ceeb34a
6
+ metadata.gz: 4b76b7c08c99da504f7c90bbfb79f6f3130345e128c0fad9988a1449270d79019ffa5af8fb62a0759ad881c56019ffe6d9f6b967d13136e988b9e98ef6d7043e
7
+ data.tar.gz: d3f2d2573c6d41140ceff2395c4aa84194eb10ad2c56379df133f1fa1687424b009513800e4c67d1077584750146c2f5b9e0dfedfb53d2b9a351c5f207f80636
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 — 2026-09-14
4
+
5
+ Released alongside Mailbox Kit 0.1.0. Cloudflare installs the core automatically.
6
+ Existing installations should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md).
7
+
8
+ - Allow receiving domains without a sending account. The upgrade generator
9
+ preserves existing directory records while making account ownership optional.
10
+
11
+ - Index inbox memberships by inbound email for recipient isolation checks,
12
+ retention and purge. New schemas include the index; `mailbox_kit:upgrade`
13
+ generates a data-preserving migration for existing installations.
14
+ - Delegate shared configuration to Mailbox Kit and isolate Cloudflare session
15
+ additions in an integration module. Provide a short core quickstart plus
16
+ packaged integration and upgrade guides.
17
+
18
+ - Lean on Action Mailbox for original email records, processing and configurable
19
+ retention. Add idempotent attachment of existing Rails records and source-based
20
+ receiving that resolves duplicates for inbox membership. Share the persistence
21
+ bridge with Cloudflare while preserving its signed delivery identity and legacy
22
+ duplicate return value. Reuse Rails' model load hook for selective retention.
23
+
24
+ - Extract provider-neutral mailbox storage, recipient routing, optional tenancy,
25
+ raw-message retention, Rails job context and the server-rendered UI into the
26
+ sibling `mailbox-kit` gem. Cloudflare APIs, Worker transport, sending outbox and
27
+ delivery feedback remain in `cloudflare-email`. Preserve existing table names,
28
+ public constants, job payload keys and mounted Cloudflare engine routes.
29
+ See [Mailbox Kit](mailbox-kit/README.md) for standalone setup and release order.
30
+
31
+ - Add a cited one-time domain setup guide for both receiving domains and dynamic
32
+ organization subdomains, shared between Worker templates. Add a read-only
33
+ `npm run check:subdomains` DNS helper with named/fresh probes and explicit limits
34
+ on what DNS can verify. The Worker transport and gem routing behavior are unchanged.
35
+
3
36
  ## 0.3.0 — 2026-09-13
4
37
 
5
38
  See [upgrading from 0.2](docs/upgrading-0.3.md) before deploying the updated Worker.
data/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Ruby client for [Cloudflare Email Service](https://developers.cloudflare.com/email-service/), with ActionMailer, authenticated ActionMailbox ingress, a forwarding Worker, and optional durable Rails sending and delivery-event tracking.
4
4
 
5
- Version **0.3.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1. Supported test floors are Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. Prefer a maintained Ruby/Rails release for new applications. The plain Ruby client uses Ruby's standard libraries plus the Base64 gem. Node is optional: Worker deployment also works through the included Ruby deployer. See [security guidance](SECURITY.md) for deployment responsibilities.
5
+ Version **0.4.0**, with **Mailbox Kit 0.1.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1. Supported test floors are Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. Prefer a maintained Ruby/Rails release for new applications. Rails and database dependencies remain optional for the plain Ruby client. Node is optional: Worker deployment also works through the included Ruby deployer. See [security guidance](SECURITY.md) for deployment responsibilities.
6
6
 
7
7
  [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
8
8
 
@@ -10,6 +10,13 @@ Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [P
10
10
 
11
11
  ## Start here
12
12
 
13
+ **Starting in 0.4.0:** provider-neutral mailbox functionality lives in
14
+ the sibling [Mailbox Kit](mailbox-kit/README.md) gem. Existing Cloudflare setup
15
+ continues to work; the core requires no Cloudflare account. Installing
16
+ `cloudflare-email` also installs `mailbox-kit` 0.1.x.
17
+ Existing mailbox installations should use the [core upgrade guide](mailbox-kit/docs/upgrading.md)
18
+ to add the inbound lookup index without recreating their tables.
19
+
13
20
  **Sending email?** Start with the [step-by-step Rails guide](docs/getting-started.md).
14
21
  **Building a mailbox?** Follow the same guide through receiving, the SQLite-compatible outbox, and delivery tracking.
15
22
  **Using plain Ruby?** Jump to [Plain Ruby](#plain-ruby); Rails and a database are optional.
@@ -18,6 +25,8 @@ Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [P
18
25
  | --- | --- |
19
26
  | [Features at a glance](docs/features.md) | Everything the gem handles, and what your app supplies |
20
27
  | [Getting started](docs/getting-started.md) | Install, send your first email, receive replies, and save reliable send operations |
28
+ | [Cloudflare domain setup](templates/worker/docs/domain-setup.md) | Configure once for many mailboxes or dynamic organization subdomains, with a Rebulk example and DNS checks |
29
+ | [Hello-world Rails template](https://github.com/cole-robertson/cloudflare-email-rails-starter) | Clone a minimal SQLite mailbox app using the published gem and its management UI |
21
30
  | [Troubleshooting](docs/troubleshooting.md) | What to check when mail or delivery updates do not arrive |
22
31
  | [Managed mailboxes](docs/mailboxes.md) | Create inboxes and aliases, read/archive mail, and send from a mailbox |
23
32
  | [Management engine](docs/management-engine.md) | Mount an optional server-rendered mailbox UI using your app's authentication |
@@ -29,16 +38,17 @@ Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [P
29
38
  | [SQLite tenant databases](docs/activerecord-tenanted.md) | Give each organization its own SQLite database with `activerecord-tenanted` |
30
39
  | [Durable outbox](docs/outbox.md) | Detailed setup, callbacks, retries, and recovery |
31
40
  | [Delivery events](docs/delivery-events.md) | Cloudflare Queue setup and recipient status tracking |
32
- | [Upgrading to 0.3](docs/upgrading-0.3.md) | Changes needed for an existing installation |
41
+ | [Upgrading to 0.4](mailbox-kit/docs/upgrading.md) | Core extraction and data-preserving migrations for existing mailboxes |
42
+ | [Upgrading from 0.2](docs/upgrading-0.3.md) | Additional changes before deploying the durable Worker |
33
43
 
34
44
  The sections below are the configuration and API reference.
35
45
 
36
46
  ## Install and send from Rails
37
47
 
38
- Add version 0.3 to your Gemfile. Existing users should follow the [upgrade guide](docs/upgrading-0.3.md), especially before deploying the durable Worker:
48
+ Add version 0.4 to your Gemfile. Existing mailbox users should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md). When upgrading from 0.2, also follow the [durable Worker upgrade guide](docs/upgrading-0.3.md):
39
49
 
40
50
  ```ruby
41
- gem "cloudflare-email", "~> 0.3.0"
51
+ gem "cloudflare-email", "~> 0.4.0"
42
52
  ```
43
53
 
44
54
  ```sh
@@ -35,9 +35,8 @@ module Cloudflare
35
35
  head :payload_too_large
36
36
  when :ok
37
37
  inbound = if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?
38
- recipient = result.message.envelope.fetch("to")
39
38
  begin
40
- Cloudflare::Email::Mailboxes.receive(recipient: recipient) { result.message.persist_action_mailbox! }
39
+ result.message.receive_into_mailbox!
41
40
  rescue Cloudflare::Email::Mailboxes::Unavailable
42
41
  payload[:result] = :unavailable_mailbox
43
42
  next head(:unprocessable_entity)
@@ -1,172 +1,8 @@
1
+ require File.join(MailboxKit::ROOT, "app/controllers/mailbox_kit/management/mailboxes_controller.rb")
1
2
  module Cloudflare
2
3
  module Email
3
4
  module Management
4
- class MailboxesController < ActionController::Base
5
- layout "layouts/cloudflare/email/management"
6
- protect_from_forgery with: :exception
7
- self.forgery_protection_origin_check = true
8
- around_action :with_host_context
9
- helper_method :allowed?, :host_back_path
10
-
11
- class Denied < StandardError; end
12
- class InvalidInput < StandardError; end
13
-
14
- rescue_from Denied do
15
- head :forbidden
16
- end
17
- rescue_from "ActiveRecord::RecordNotFound" do
18
- head :not_found
19
- end
20
- rescue_from InvalidInput, "ActiveRecord::RecordInvalid", Cloudflare::Email::Error do
21
- redirect_to(@mailbox ? mailbox_path(@mailbox) : root_path,
22
- alert: "The changes could not be saved. Check the address, registered domain and required fields.",
23
- status: :see_other)
24
- end
25
- rescue_from Cloudflare::Email::ConfigurationError do
26
- head :service_unavailable
27
- end
28
-
29
- def index
30
- authorize!(:index)
31
- @mailboxes = page(scoped_mailboxes)
32
- @domains = permitted_domains if allowed?(:create)
33
- end
34
-
35
- def show
36
- load_mailbox!(:show)
37
- @addresses = @session.addresses(@mailbox.id).order(:id).to_a
38
- @domains = allowed?(:add_address, @mailbox) ? permitted_domains : []
39
- @messages = allowed?(:show_message, @mailbox) ? page(@session.messages(@mailbox.id)) : []
40
- end
41
-
42
- def create
43
- authorize!(:create)
44
- input = params.require(:mailbox)
45
- raise InvalidInput unless input.is_a?(ActionController::Parameters)
46
- values = input.permit(:name, :address)
47
- name = values[:name].to_s.strip
48
- raise InvalidInput unless (1..255).cover?(name.length)
49
- address = permitted_address!(values[:address])
50
- Mailboxes::Mailbox.transaction do
51
- @mailbox = @adapter.create_mailbox(@session, name: name, address: address)
52
- # A hook cannot turn an arbitrary return value into a readable mailbox.
53
- @mailbox = scoped_mailboxes.find(@mailbox.id)
54
- end
55
- redirect_to mailbox_path(@mailbox), notice: "Mailbox created. Review address setup below.", status: :see_other
56
- end
57
-
58
- def add_address
59
- load_mailbox!(:add_address)
60
- @adapter.add_address(@session, @mailbox, address: permitted_address!(params[:address]))
61
- redirect_to mailbox_path(@mailbox), notice: "Address added. Review its setup status.", status: :see_other
62
- end
63
-
64
- def suspend
65
- load_mailbox!(:suspend)
66
- @session.suspend(@mailbox.id)
67
- redirect_to mailbox_path(@mailbox), notice: "Mailbox suspended. Messages are retained.", status: :see_other
68
- end
69
-
70
- def resume
71
- load_mailbox!(:resume)
72
- @session.resume(@mailbox.id)
73
- redirect_to mailbox_path(@mailbox), notice: "Mailbox resumed. Pending addresses still need setup.", status: :see_other
74
- end
75
-
76
- def message
77
- load_mailbox!(:show_message)
78
- @entry = @session.messages(@mailbox.id).find(params[:message_id])
79
- raw = @session.inbound_email(@mailbox.id, @entry.id).mail
80
- @subject = raw.subject.to_s
81
- @from = Array(raw.from).join(", ")
82
- part = raw.text_part || (raw.mime_type == "text/plain" ? raw : nil)
83
- @body = part ? part.decoded.to_s.encode("UTF-8", invalid: :replace, undef: :replace).first(100_000) :
84
- "This message has no plain-text body. HTML rendering is disabled in this management interface."
85
- @attachments = raw.attachments.map { |attachment| attachment.filename.to_s }
86
- render :message
87
- end
88
-
89
- def mark_read
90
- load_mailbox!(:mark_read)
91
- @session.mark_read(@mailbox.id, params[:message_id], read: boolean_param(:read))
92
- redirect_to mailbox_path(@mailbox), status: :see_other
93
- end
94
-
95
- def archive
96
- load_mailbox!(:archive)
97
- @session.archive(@mailbox.id, params[:message_id], archived: boolean_param(:archived))
98
- redirect_to mailbox_path(@mailbox), status: :see_other
99
- end
100
-
101
- private
102
-
103
- def with_host_context
104
- response.headers["Cache-Control"] = "no-store"
105
- response.headers["Referrer-Policy"] = "same-origin"
106
- response.headers["X-Content-Type-Options"] = "nosniff"
107
- response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
108
- factory = Management.configuration.adapter
109
- unless factory.respond_to?(:call) && defined?(Mailboxes) && Mailboxes.enabled?
110
- return head :service_unavailable
111
- end
112
- @adapter = factory.call(self)
113
- authenticated = @adapter.authenticate!
114
- return if performed?
115
- return head :unauthorized unless authenticated == true
116
- Mailboxes.for_tenant(@adapter.tenant_key) do |session|
117
- @session = session
118
- yield
119
- end
120
- end
121
-
122
- def allowed?(action, mailbox = nil)
123
- @adapter.allowed?(action, mailbox) == true
124
- end
125
-
126
- def authorize!(action, mailbox = nil)
127
- raise Denied unless allowed?(action, mailbox)
128
- end
129
-
130
- def scoped_mailboxes
131
- # Always retain the gem's tenant condition even if a host scope omits it.
132
- @session.mailboxes.where(id: @adapter.mailboxes(@session).reselect(:id))
133
- end
134
-
135
- def load_mailbox!(action)
136
- @mailbox = scoped_mailboxes.find(params[:id])
137
- authorize!(action, @mailbox)
138
- end
139
-
140
- def permitted_domains
141
- granted = Array(@adapter.domains(@session)).map(&:to_s)
142
- Mailboxes::ReceivingDomain.active.where(tenant_key: @session.tenant_key, domain: granted).order(:domain).pluck(:domain)
143
- end
144
-
145
- def permitted_address!(value)
146
- address = Mailboxes.canonical_address(value)
147
- raise InvalidInput unless permitted_domains.include?(address.split("@", 2).last)
148
- address
149
- end
150
-
151
- def page(relation)
152
- cursor = params[:after].to_s
153
- raise InvalidInput unless cursor.empty? || cursor.match?(/\A[0-9]{1,18}\z/)
154
- rows = relation.where("id > ?", cursor.to_i).order(:id).limit(51).to_a
155
- @next_cursor = rows.length > 50 ? rows[49].id : nil
156
- rows.first(50)
157
- end
158
-
159
- def boolean_param(key)
160
- value = params[key]
161
- raise InvalidInput unless %w[true false].include?(value)
162
- value == "true"
163
- end
164
-
165
- def host_back_path
166
- callback = Management.configuration.back_path
167
- value = callback.call(self) if callback.respond_to?(:call)
168
- value if value.is_a?(String) && value.start_with?("/") && !value.start_with?("//") && !value.match?(/[\\\r\n]/)
169
- end
5
+ class MailboxesController < MailboxKit::Management::MailboxesController
170
6
  end
171
7
  end
172
8
  end
@@ -1,12 +1,8 @@
1
+ require File.join(MailboxKit::ROOT, "app/controllers/mailbox_kit/management/styles_controller.rb")
1
2
  module Cloudflare
2
3
  module Email
3
4
  module Management
4
- class StylesController < ActionController::Base
5
- def show
6
- response.headers["X-Content-Type-Options"] = "nosniff"
7
- send_data File.binread(File.expand_path("../../../../../lib/cloudflare/email/management/management.css", __dir__)),
8
- type: "text/css; charset=utf-8", disposition: "inline"
9
- end
5
+ class StylesController < MailboxKit::Management::StylesController
10
6
  end
11
7
  end
12
8
  end
@@ -42,6 +42,10 @@ class InboundEmailsController < ActionController::API
42
42
 
43
43
  verified = result.message
44
44
 
45
+ # If no custom acceptance/processing record is needed, this is sufficient:
46
+ # verified.receive_into_mailbox!
47
+ # It also repairs missing membership on duplicate delivery without rerouting.
48
+
45
49
  Cloudflare::Email::Mailboxes.receive(
46
50
  recipient: verified.envelope.fetch("to")
47
51
  ) do |destination|
@@ -64,7 +68,7 @@ Mount this controller at your own route and point your existing Worker there. Th
64
68
 
65
69
  Do not use the MIME `To` header or an unauthenticated URL subdomain to select storage. `verified.envelope.fetch("to")` is the signed SMTP recipient. `Mailboxes.receive` checks that its domain, mailbox, and accepted address are active before entering the persistence block.
66
70
 
67
- `persist_action_mailbox!` stores the verified raw bytes and metadata in Action Mailbox. The receiving block adds mailbox membership in the same tenant transaction. Rails schedules normal routing after the transaction commits. Apply policies that must stop processing before calling persistence; a check performed after receiving returns can be too late.
71
+ `persist_action_mailbox!` delegates verified raw bytes and metadata to the core's Rails persistence bridge. It preserves the existing new-record-or-`nil` return convention; the block example therefore does no new work for a duplicate. The default `receive_into_mailbox!` bridge also attaches the existing record, repairing a missing membership without reprocessing. Rails schedules normal routing only for newly created records after the transaction commits. Apply policies that must stop processing before calling persistence; a check performed after receiving returns can be too late.
68
72
 
69
73
  For an application that owns a different raw-email store, use `verified.body`, `verified.envelope`, `verified.provider_metadata`, `verified.message_checksum`, and `verified.storage_metadata` with your own persistence/transaction system. `Mailboxes.receive` specifically expects an Action Mailbox inbound email record (or nil) from its block; do not pass an unrelated processing record. The gem does not choose your archive retention, held-message model, or document queue.
70
74
 
data/docs/features.md CHANGED
@@ -5,7 +5,7 @@ services. You can use just the sending client, add incoming mail, or build a
5
5
  mailbox on top of the optional database-backed delivery tools.
6
6
 
7
7
  Start with [Getting started](getting-started.md) for working examples. These
8
- features are available in **0.3.0**. Database multi-tenancy is **off by default**:
8
+ features are available in **0.4.0**, which includes **Mailbox Kit 0.1.0**. Database multi-tenancy is **off by default**:
9
9
  the optional mailbox module works in one database unless you explicitly configure
10
10
  a tenant connection adapter. A mailbox tenant key alone does not switch databases.
11
11
 
@@ -18,11 +18,12 @@ not need to be the same domain as your Rails application's web address.
18
18
 
19
19
  ### Install the current code
20
20
 
21
- Add version 0.3 to your app's `Gemfile`. If upgrading an existing installation, read the
22
- [upgrade guide](upgrading-0.3.md) before changing an existing Worker deployment:
21
+ Add version 0.4 to your app's `Gemfile`. Existing mailbox installations should read
22
+ the [core upgrade guide](../mailbox-kit/docs/upgrading.md). When upgrading from 0.2,
23
+ also read the [Worker upgrade guide](upgrading-0.3.md):
23
24
 
24
25
  ```ruby
25
- gem "cloudflare-email", "~> 0.3.0"
26
+ gem "cloudflare-email", "~> 0.4.0"
26
27
  ```
27
28
 
28
29
  ```sh
data/docs/mailboxes.md CHANGED
@@ -5,7 +5,7 @@ mail into them, track read/archive state, and send through the durable outbox.
5
5
  It works in one SQLite database or with a separate database per organization.
6
6
  It supplies models and services; your app supplies permissions and the UI.
7
7
 
8
- This module is available since version 0.2.0. Install `gem "cloudflare-email", "~> 0.3.0"` for the latest features.
8
+ This module is available since version 0.2.0. Install `gem "cloudflare-email", "~> 0.4.0"` for the latest features, including the provider-neutral Mailbox Kit core.
9
9
 
10
10
  **Database multi-tenancy is off by default.** The mailbox generator works with
11
11
  one ordinary database. Calling `for_tenant` groups and scopes mailbox records;
@@ -45,6 +45,13 @@ use the same tenant connection as the mailbox tables for atomic incoming storage
45
45
 
46
46
  ## Register an organization's domain
47
47
 
48
+ For addresses such as `invoices@acme.in.example.com`, follow the
49
+ [Cloudflare domain setup guide](../templates/worker/docs/domain-setup.md).
50
+ It covers Rebulk's existing wildcard receiving pattern, one-time infrastructure
51
+ verification, and exact organization-domain registration in Rails. The gem does
52
+ not require a per-organization Worker allowlist or Cloudflare approval once that
53
+ receiving infrastructure is established; new accounts must verify the provider behavior.
54
+
48
55
  Run directory management from your authorized administration/provisioning code.
49
56
  Do not expose arbitrary domain claims to customers without ownership checks.
50
57
 
@@ -6,6 +6,13 @@ A mailbox registered in Rails is not proof that Cloudflare can deliver to it.
6
6
  Use this read-only check when onboarding a receiving domain, investigating a
7
7
  missing message, or checking an existing catch-all before activating addresses.
8
8
 
9
+ For wildcard organization subdomains, also use the
10
+ [domain setup guide and public DNS checker](../templates/worker/docs/domain-setup.md).
11
+ This API inspects exact-domain configured records and does not resolve wildcard
12
+ inheritance. Missing exact records can therefore coexist with working inherited
13
+ MX. Public DNS and real delivery checks provide separate evidence; this diagnostic
14
+ is not a requirement to manually onboard every organization in Cloudflare.
15
+
9
16
  ```sh
10
17
  bin/rails cloudflare:email:check_route \
11
18
  ADDRESS=houston@customer.example.com \
@@ -9,7 +9,7 @@ into public logs or issues.
9
9
 
10
10
  | What you see | What to check next |
11
11
  | --- | --- |
12
- | New generator or API is missing | Check `Gemfile.lock`. The new features require the [0.2 commit](getting-started.md#install-the-current-code); published 0.1.0 lacks them. |
12
+ | New generator or API is missing | Check `Gemfile.lock`. Use cloudflare-email 0.4.x with mailbox-kit 0.1.x; follow the [installation guide](getting-started.md#install-the-current-code). |
13
13
  | Credentials appear to be ignored | Nonempty Rails credentials override environment variables. Check the Rails environment and restart the app after changes. |
14
14
  | Authentication or domain error | Run `bin/rails cloudflare:email:doctor`; check account ID, token permissions and sending-domain verification. |
15
15
  | `doctor` reports limited read access | A send token may lack diagnostic read permissions. Review the specific result; diagnostics alone cannot prove whether sending works. |
@@ -0,0 +1,96 @@
1
+ # Action Mailbox composition verification
2
+
3
+ ## Independent review follow-up
4
+
5
+ The independent architecture review of `63660eb` confirmed the Rails/core/provider
6
+ split and reproduced a missing inbound membership index with SQLite's query planner.
7
+ The follow-up adds that index to both installers and supplies `mailbox_kit:upgrade`
8
+ for existing schemas. The generator fixture verifies indexed lookup, existing
9
+ membership preservation and repeat application. It passes 4 tests / 27 assertions.
10
+
11
+ Cloudflare configuration now delegates shared validation to the core, and its
12
+ session behavior is an explicit integration module. Handler overrides retain
13
+ their existing extension point. The short core README links to packaged
14
+ integration and upgrade guides.
15
+
16
+ After these changes, the full suite passes 289 tests / 1,151 assertions. Package
17
+ verification passes for Rails-free Cloudflare, packaged ingress/outbound/tenancy,
18
+ and an independent Rails consumer without Cloudflare in its bundle.
19
+
20
+ This is local/package verification, not a Rebulk production rollout. Automatic
21
+ model-owner lifecycle bindings and a universal outbound adapter framework remain
22
+ outside this change. Existing application acceptance, ownership and business
23
+ processing claims must be retained.
24
+
25
+ Mailbox Kit now treats `ActionMailbox::InboundEmail` as the original email record.
26
+ The kit's `Message` remains an inbox membership with read/archive state; no new
27
+ raw-email table, parser, routing job or processing-status model was introduced.
28
+
29
+ ## Verified behavior
30
+
31
+ - `Mailboxes.receive(recipient:, source:)` resolves the trusted receiving scope
32
+ before Rails persistence, and attaches the existing record on source replay.
33
+ Identical source in two inboxes of one tenant produces one Rails email, two
34
+ memberships and one routing job. Read state remains independent per inbox.
35
+ - Identical source in different tenants sharing a database produces distinct
36
+ Rails records/jobs. `Session#attach` rejects a row already associated with a
37
+ different tenant, and resolves IDs only inside the selected connection.
38
+ - `Session#attach(recipient:, inbound_email_id:)` supports already-stored Rails
39
+ mail. It is idempotent per mailbox/email, preserves alias reservations and
40
+ refuses suspended addresses or missing records.
41
+ - The stock Rails Postmark ingress was exercised through HTTP in the isolated
42
+ Rails fixture. Duplicate webhook payloads produced one Rails email; explicit
43
+ host-authorized attachment created its inbox membership without reprocessing.
44
+ - Cloudflare retains its prior authenticated envelope/metadata identity and
45
+ stable Message-ID fallback. Its controller uses `receive_into_mailbox!` and
46
+ repairs missing membership on replay without re-enqueueing Rails routing.
47
+ The older `persist_action_mailbox!` still returns nil on duplicate delivery.
48
+ - Rails' `incinerate = false` prevents automatic cleanup scheduling without a
49
+ membership. With normal cleanup enabled, aged processed unassociated mail is
50
+ removed while inbox-associated mail survives. The guard uses Rails' public
51
+ inbound-email load hook and the same row lock as attachment and purge.
52
+ - Removing one of two memberships preserves the shared raw email; removing the
53
+ last through explicit purge removes it. Failed membership persistence rolls
54
+ back Rails database records and prevents a routing enqueue.
55
+ - PostgreSQL deliveries were synchronized at Rails creation to force a unique
56
+ conflict. Both calls completed with one inbound record, one membership, one
57
+ Active Storage blob record and one routing job. A savepoint rollback keeps the
58
+ losing transaction usable before looking up the winning record.
59
+
60
+ ## Checks
61
+
62
+ - Full Ruby suite: 289 top-level tests, 1,151 assertions passed. Integration
63
+ subprocesses exercise the additional Rails behavior rather than inflating these
64
+ top-level counts.
65
+ - `script/verify_package.rb`: both gem archives built; Rails-free Cloudflare
66
+ consumer, packaged Cloudflare integration and independent core-only Rails
67
+ consumer passed. The independent bundle contains no Cloudflare gem.
68
+ - PostgreSQL inbound suite: 25 tests, 273 assertions passed on a disposable local
69
+ PostgreSQL 15 instance. CI runs the same suite against PostgreSQL 16 alongside
70
+ the existing outbox tests.
71
+ - SQLite core/compatibility, separate-tenant ingress and custom ingress regression
72
+ suites were exercised locally. CI covers the supported Ruby/Rails matrix.
73
+
74
+ Run the PostgreSQL check only against a disposable database:
75
+
76
+ ```sh
77
+ BUNDLE_GEMFILE=gemfiles/postgres.gemfile \
78
+ POSTGRES_TEST_URL=postgres://localhost/cloudflare_email_test \
79
+ bundle exec ruby script/verify_postgres_inbound.rb
80
+ ```
81
+
82
+ The script creates a unique schema and drops that schema when its fixture exits.
83
+
84
+ ## Limits
85
+
86
+ The Postmark test is a fixed, authorized single-database route, not a live
87
+ Postmark deployment or a complete dynamic-tenant provider adapter. The caller
88
+ must authenticate ingress and authorize the envelope recipient and inbound ID;
89
+ MIME To/X-Original-To headers are not tenant entitlement. Tenant selection after
90
+ ingress cannot relocate raw mail already persisted to another database.
91
+
92
+ Raw blob uploads and external side effects are not made transactional by Active
93
+ Record. This check does not establish exactly-once business effects, provider
94
+ outage recovery, lost-job recovery, or live delivery. Existing Cloudflare durable
95
+ transport remains responsible for its outage/retry behavior. Owner lifecycle
96
+ macros and a general outbound adapter framework are separate work.
@@ -1,60 +1,10 @@
1
- require "active_record"
2
1
  require "cloudflare/email/tenancy"
3
-
2
+ require "mailbox_kit/active_record/base"
4
3
  module Cloudflare
5
4
  module Email
6
5
  module ActiveRecord
7
- # Rails may inspect every model's pool while preloading schema metadata
8
- # before any request has selected a tenant. Report an unavailable
9
- # connection using Rails' error hierarchy so boot can recover, while
10
- # retaining the same fail-closed guard for every caller.
11
- class TenantConnectionUnavailable < ::ActiveRecord::ConnectionNotEstablished; end
12
-
13
- # Uses the host's abstract tenant connection owner when explicitly configured.
14
- class Base < Tenancy.model_base(::ActiveRecord::Base)
15
- self.abstract_class = true
16
-
17
- class << self
18
- def connection_pool
19
- if Tenancy.enabled?
20
- begin
21
- Tenancy.require_context!
22
- rescue ConfigurationError => error
23
- raise TenantConnectionUnavailable, error.message
24
- end
25
- end
26
- super
27
- end
28
- end
29
-
30
- before_validation :verify_cloudflare_email_tenant!
31
- before_save :verify_cloudflare_email_tenant!
32
- before_destroy :verify_cloudflare_email_tenant!
33
-
34
- # These methods can bypass callbacks or load a different row with the same ID.
35
- %i[reload update_columns delete touch increment! decrement! association].each do |method_name|
36
- define_method(method_name) do |*args, **kwargs, &block|
37
- verify_cloudflare_email_tenant!
38
- super(*args, **kwargs, &block)
39
- end
40
- end
41
-
42
- private
43
-
44
- # Both new construction and persisted-row instantiation call this before
45
- # assigning inverse associations (which precede after_initialize).
46
- def init_internals
47
- super
48
- @cloudflare_email_tenant_key = Tenancy.require_context! if Tenancy.enabled?
49
- end
50
-
51
- def verify_cloudflare_email_tenant!
52
- return unless Tenancy.enabled?
53
- unless @cloudflare_email_tenant_key == Tenancy.require_context!
54
- raise ConfigurationError, "record belongs to a different tenant context"
55
- end
56
- end
57
- end
6
+ Base = MailboxKit::ActiveRecord::Base
7
+ TenantConnectionUnavailable = MailboxKit::ActiveRecord::TenantConnectionUnavailable
58
8
  end
59
9
  end
60
10
  end
@@ -1,4 +1,5 @@
1
1
  require "rails/engine"
2
+ require "mailbox_kit/railtie"
2
3
  require "cloudflare/email/dev_ingress_guard"
3
4
 
4
5
  # Register the delivery method at engine load time (not inside an initializer)
@@ -18,19 +19,6 @@ module Cloudflare
18
19
  app.middleware.insert_before 0, DevIngressGuard if Rails.env.development?
19
20
  end
20
21
 
21
- config.to_prepare do
22
- if (defined?(Cloudflare::Email::Tenancy) && Cloudflare::Email::Tenancy.enabled?) ||
23
- (defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?)
24
- require "cloudflare/email/tenant_job_context"
25
- Cloudflare::Email::TenantJobContext.install_framework_jobs!
26
- end
27
- if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled? && defined?(::ActionMailbox::Engine)
28
- require "cloudflare/email/mailboxes/inbound_retention"
29
- ::ActionMailbox::InboundEmail.prepend(Cloudflare::Email::Mailboxes::InboundRetention) unless
30
- ::ActionMailbox::InboundEmail.ancestors.include?(Cloudflare::Email::Mailboxes::InboundRetention)
31
- end
32
- end
33
-
34
22
  config.before_initialize do
35
23
  unless defined?(Cloudflare::Email::Management::Engine)
36
24
  Rails.autoloaders.main.ignore(
@@ -1,3 +1,4 @@
1
+ require "mailbox_kit/address_syntax"
1
2
  require "json"
2
3
  require "cloudflare/email/signing"
3
4
 
@@ -8,19 +9,8 @@ module Cloudflare
8
9
  module Envelope
9
10
  METADATA_KEY = "cloudflare_email_envelope".freeze
10
11
  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
12
  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) }
13
+ MailboxKit::AddressSyntax.valid_address?(address, allow_empty: allow_empty)
24
14
  end
25
15
 
26
16
  def self.valid?(value)
@@ -1,18 +1,11 @@
1
+ require "mailbox_kit/error"
2
+
1
3
  module Cloudflare
2
4
  module Email
3
- class Error < StandardError
4
- attr_reader :response, :status
5
-
6
- def initialize(message = nil, status: nil, response: nil)
7
- super(message)
8
- @status = status
9
- @response = response
10
- end
11
- end
12
-
13
- class ConfigurationError < Error; end
5
+ Error = MailboxKit::Error
6
+ ConfigurationError = MailboxKit::ConfigurationError
14
7
  class AuthenticationError < Error; end
15
- class ValidationError < Error; end
8
+ ValidationError = MailboxKit::ValidationError
16
9
  class RateLimitError < Error; end
17
10
  class ServerError < Error; end
18
11
  class NetworkError < Error; end