cloudflare-email 0.2.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -0
  3. data/README.md +44 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +15 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +9 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +9 -0
  7. data/docs/activerecord-tenanted.md +46 -0
  8. data/docs/architecture.md +13 -3
  9. data/docs/custom-ingress.md +211 -0
  10. data/docs/delivery-events.md +9 -0
  11. data/docs/features.md +36 -5
  12. data/docs/getting-started.md +19 -8
  13. data/docs/mailboxes.md +74 -3
  14. data/docs/management-engine.md +190 -0
  15. data/docs/routing-deliveries.md +94 -0
  16. data/docs/routing-diagnostics.md +75 -0
  17. data/docs/troubleshooting.md +5 -4
  18. data/docs/upgrading-0.2.md +5 -0
  19. data/docs/upgrading-0.3.md +55 -0
  20. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  21. data/docs/verification/2026-09-12-destination-context.md +46 -0
  22. data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
  23. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  24. data/lib/cloudflare/email/active_record/base.rb +3 -41
  25. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  26. data/lib/cloudflare/email/check_route_task.rb +21 -0
  27. data/lib/cloudflare/email/client.rb +45 -10
  28. data/lib/cloudflare/email/delivery_method.rb +2 -0
  29. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  30. data/lib/cloudflare/email/engine.rb +6 -13
  31. data/lib/cloudflare/email/envelope.rb +4 -14
  32. data/lib/cloudflare/email/error.rb +5 -12
  33. data/lib/cloudflare/email/event_consumer.rb +12 -3
  34. data/lib/cloudflare/email/ingress.rb +126 -0
  35. data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
  36. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
  37. data/lib/cloudflare/email/mailboxes/models.rb +8 -130
  38. data/lib/cloudflare/email/mailboxes/service.rb +7 -174
  39. data/lib/cloudflare/email/mailboxes.rb +1 -0
  40. data/lib/cloudflare/email/management/adapter.rb +9 -0
  41. data/lib/cloudflare/email/management/configuration.rb +10 -0
  42. data/lib/cloudflare/email/management/engine.rb +17 -0
  43. data/lib/cloudflare/email/management/routes.rb +14 -0
  44. data/lib/cloudflare/email/management.rb +7 -0
  45. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  46. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  47. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  48. data/lib/cloudflare/email/tenancy.rb +2 -73
  49. data/lib/cloudflare/email/tenant_job_context.rb +2 -85
  50. data/lib/cloudflare/email/verification.rb +17 -9
  51. data/lib/cloudflare/email/version.rb +1 -1
  52. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  53. data/lib/cloudflare-email.rb +6 -0
  54. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  55. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  56. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  57. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +5 -0
  58. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
  59. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  60. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  61. data/lib/tasks/cloudflare_email.rake +2 -1
  62. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  63. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  64. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  65. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  66. data/templates/deploy-to-cloudflare/README.md +104 -0
  67. data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
  68. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  69. data/templates/deploy-to-cloudflare/package.json +37 -0
  70. data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
  71. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  72. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  73. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  74. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  75. data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
  76. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  77. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  78. data/templates/worker/README.md +186 -15
  79. data/templates/worker/docs/domain-setup.md +198 -0
  80. data/templates/worker/docs/durable-inbound.md +149 -0
  81. data/templates/worker/package-lock.json +2 -2
  82. data/templates/worker/package.json +2 -1
  83. data/templates/worker/scripts/check-subdomains.mjs +54 -0
  84. data/templates/worker/src/index.js +331 -38
  85. data/templates/worker/test/durable.test.ts +193 -0
  86. data/templates/worker/test/index.test.ts +78 -20
  87. data/templates/worker/test/relay.test.ts +226 -0
  88. data/templates/worker/test/subdomains.test.ts +51 -0
  89. data/templates/worker/wrangler.toml +50 -0
  90. metadata +65 -3
@@ -0,0 +1,211 @@
1
+ # Use your existing inbound email pipeline
2
+
3
+ This integration API is available in gem version 0.3.0.
4
+
5
+ You can adopt mailbox registration without replacing your Worker, document processing, sender review, or archive. Use the gem to authenticate the request and resolve an accepted address, then keep your application's policy and processing.
6
+
7
+ A useful starting point is one mailbox per site: an organization owns a receiving domain, a site owns a mailbox, and that mailbox can have several accepted addresses. This is an application convention, not a requirement of the gem. Database tenancy is optional; a single SQLite database works too.
8
+
9
+ ## Register addresses before accepting mail
10
+
11
+ Follow [mailbox setup](mailboxes.md) to register and activate the receiving domain and accepted addresses. With existing catch-all Worker routing, address creation can be a database operation once an administrator has confirmed that routing covers the exact domain. Registering a domain does not create DNS records or extend wildcard coverage.
12
+
13
+ Use the same provisioning service from organization/site onboarding and from the management UI. Keep a stable site reference in the mailbox's `owner_ref`; keep site permissions, slug changes, document workflows, and sender allowlists in your app. Treat a rename as an explicit alias/lifecycle decision so old addresses are not accidentally lost.
14
+
15
+ For separate organization databases, configure [the SQLite tenant adapter](activerecord-tenanted.md) before loading mailbox models. The shared directory resolves the signed recipient to a tenant. Action Mailbox, Active Storage, and the gem's mailbox records must all use that tenant's connection.
16
+
17
+ ## Verify a custom endpoint before writing anything
18
+
19
+ `Cloudflare::Email::Ingress.verify` is usable outside the bundled controller. It accepts a String or IO body, bounds the raw body size, and returns an immutable result. Successful verification exposes the exact binary body and authenticated envelope; rejected requests expose no verified message.
20
+
21
+ The following controller is a starting point for a host with registered mailboxes:
22
+
23
+ ```ruby
24
+ require "cloudflare/email/ingress"
25
+
26
+ class InboundEmailsController < ActionController::API
27
+ def create
28
+ result = Cloudflare::Email::Ingress.verify(
29
+ secret: Rails.application.credentials.dig(:cloudflare, :ingress_secret),
30
+ headers: request.headers,
31
+ body: request.body,
32
+ content_length: request.content_length,
33
+ max_email_bytes: 25 * 1024 * 1024
34
+ )
35
+
36
+ case result.status
37
+ when :too_large
38
+ return head :payload_too_large
39
+ when :bad_signature, :stale
40
+ return head :unauthorized
41
+ end
42
+
43
+ verified = result.message
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
+
49
+ Cloudflare::Email::Mailboxes.receive(
50
+ recipient: verified.envelope.fetch("to")
51
+ ) do |destination|
52
+ # This block runs in the recipient's tenant. Apply your sender/review
53
+ # policy here, before persistence schedules Action Mailbox routing.
54
+ # Your policy may raise a host error or return nil instead of persisting.
55
+ # destination.owner_ref identifies your site/team; validate it using
56
+ # your application's policy in this tenant context.
57
+ verified.persist_action_mailbox!
58
+ end
59
+
60
+ head :ok
61
+ rescue Cloudflare::Email::Mailboxes::Unavailable
62
+ head :unprocessable_entity
63
+ end
64
+ end
65
+ ```
66
+
67
+ Mount this controller at your own route and point your existing Worker there. The host owns response codes and retry policy. If your policy returns nil and you acknowledge the request, you are accepting responsibility for that email: persist a durable review record or deliberately discard it according to your application's policy. A successful verification alone does not queue processing or create a mailbox.
68
+
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.
70
+
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.
72
+
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.
74
+
75
+ ## Persist the email and link your business record
76
+
77
+ Email persistence remains part of the gem's supported path. Action Mailbox stores
78
+ the raw MIME using Active Storage, the verifier saves authenticated metadata, and
79
+ `Mailboxes.receive` adds the inbox membership. Your processing/review record can
80
+ refer to that email rather than storing a second copy of its raw content:
81
+
82
+ ```ruby
83
+ Cloudflare::Email::Mailboxes.receive(
84
+ recipient: verified.envelope.fetch("to")
85
+ ) do |destination|
86
+ # Validate owner_ref and sender policy here, before any routing is scheduled.
87
+ inbound = verified.persist_action_mailbox!
88
+ if inbound
89
+ # Example host model, on the SAME tenant connection as the gem records.
90
+ EmailIntake.create!(
91
+ inbound_email_id: inbound.id,
92
+ mailbox_id: destination.mailbox_id,
93
+ owner_ref: destination.owner_ref
94
+ )
95
+ end
96
+ inbound # Return the ActionMailbox record; duplicate deliveries return nil.
97
+ end
98
+ ```
99
+
100
+ The database writes commit together. A host failure rolls back the new email,
101
+ membership and host link and prevents the routing job from being enqueued. This
102
+ does not make an R2 archive, another database, or external API calls transactional.
103
+ Host exceptions propagate; only unavailable registry destinations are translated
104
+ to `Mailboxes::Unavailable`. Existing blocks without a destination argument still
105
+ work.
106
+
107
+ Persisting through ActionMailbox schedules its normal routing after commit. Put
108
+ your processing in that route or a deliberately coordinated job pipeline. If an
109
+ email must be held, implement a routing policy that cannot process it before
110
+ approval; storing it is not itself a review gate. A routing job failure also needs
111
+ normal job retry/recovery. Receipt deduplication does not make every downstream
112
+ business side effect idempotent.
113
+
114
+ Managed mailbox membership protects raw email from ActionMailbox's normal
115
+ automatic incineration. Archiving a mailbox message keeps its content; deliberate
116
+ purging can remove it once no other membership references the email. Raw email
117
+ saved without membership follows the normal ActionMailbox lifecycle. Keep host
118
+ references, backup and R2 retention consistent with your chosen deletion policy.
119
+ Multi-tenancy remains opt-in.
120
+
121
+ ## Resolve an address with your own persistence
122
+
123
+ If your application already owns its raw-email store, use the standalone API:
124
+
125
+ ```ruby
126
+ Cloudflare::Email::Mailboxes.with_recipient(
127
+ recipient: verified.envelope.fetch("to")
128
+ ) do |destination|
129
+ # Example host-owned persistence service; it must commit durably before ACK.
130
+ ExistingEmailStore.save!(
131
+ tenant_key: destination.tenant_key,
132
+ mailbox_id: destination.mailbox_id,
133
+ recipient: destination.recipient,
134
+ raw: verified.body,
135
+ metadata: verified.storage_metadata
136
+ )
137
+ end
138
+ ```
139
+
140
+ This checks the active domain, address and mailbox, enters the configured tenant,
141
+ and yields an immutable `Destination` with `tenant_key`, `mailbox_id`, `address_id`,
142
+ `receiving_domain_id`, `recipient` and `owner_ref`. It returns the block's result,
143
+ creates no email or membership, starts no storage transaction, and requires no
144
+ ActionMailbox. Configure the optional mailbox registry and tenant adapter as usual.
145
+
146
+ Both APIs must be called after complete request verification. Destination values
147
+ are routing snapshots, not authorization tokens: an owner reference does not
148
+ prove a site still exists or that a sender may submit to it. Use records inside
149
+ their tenant context and recheck lifecycle/permissions when later work requires
150
+ it. Both APIs restore the previous tenant context when the block returns or raises.
151
+
152
+ ## Carry Worker metadata with authenticated provenance
153
+
154
+ The default Worker protocol remains v2: it authenticates the raw message and SMTP envelope. Optional v3 also authenticates a bounded metadata object:
155
+
156
+ ```json
157
+ {
158
+ "source": "cloudflare",
159
+ "data": {
160
+ "archive_key": "inbound/example.eml"
161
+ }
162
+ }
163
+ ```
164
+
165
+ The `archive_key` above is an illustrative application-defined value. The gem does not fetch that object or verify an archive exists. Your trusted Worker constructs the data from sources whose provenance you understand.
166
+
167
+ V3 binds the encoded metadata to the timestamp, envelope, and exact raw bytes. The `X-CF-Email-Metadata` header is unpadded base64url UTF-8 JSON. Metadata attached to a v2 request is rejected rather than treated as authenticated. Existing v2 requests without metadata continue to work.
168
+
169
+ The Worker template exports `signedEmailHeaders` for an existing transport and `forwardEmail` for the bundled forwarding behavior. Keep your current archive and retry handling, and use the header builder to authenticate the original raw bytes:
170
+
171
+ ```js
172
+ import { signedEmailHeaders } from "./cloudflare-ingress.js";
173
+
174
+ const headers = await signedEmailHeaders({
175
+ secret: env.INGRESS_SECRET,
176
+ raw, // Uint8Array from your bounded reader
177
+ from: message.from,
178
+ to: message.to,
179
+ metadata: { source: "cloudflare", data: { archive_key: archiveKey } },
180
+ });
181
+ // Send exactly raw with these headers using your existing transport.
182
+ ```
183
+
184
+ Here `cloudflare-ingress.js` is your local copy of the template's `src/index.js`. `raw` and `archiveKey` come from your existing bounded reader and archive operation. See the [Worker integration instructions](../templates/worker/README.md) for supported metadata values and forwarding behavior. Upgrade the Rails receiver before enabling v3 in the Worker.
185
+
186
+ In your Action Mailbox handler, read persisted metadata through the gem:
187
+
188
+ ```ruby
189
+ require "cloudflare/email/provider_metadata"
190
+
191
+ metadata = Cloudflare::Email::ProviderMetadata.for(inbound_email)
192
+ archive_key = metadata&.dig("data", "archive_key")
193
+ ```
194
+
195
+ This reads verified Active Storage metadata, not similarly named MIME headers. Authenticate your Worker first and preserve the verified object unchanged. Avoid adding secrets or unnecessary personal information to provider metadata.
196
+
197
+ **A valid Worker signature proves that your trusted Worker submitted the request. It does not prove the original email sender is authentic.** Even metadata labelled `source: "cloudflare"` is an assertion made by the holder of the ingress secret. Do not copy sender-controlled MIME headers into a “trusted authentication” field. If your existing Worker has reliable authentication evidence, preserve its documented provenance and let your application decide how that evidence affects acceptance. The gem intentionally does not infer SPF/DMARC results or implement a sender allowlist.
198
+
199
+ Retries with the same recipient, raw bytes, and metadata context reuse the inbound record. Aliases receive distinct membership context. Changing signed metadata produces a distinct checksum: stable values are preferable to per-attempt timestamps or random IDs. Keep downstream document extraction and external side effects idempotent too.
200
+
201
+ ## Adopt incrementally
202
+
203
+ 1. Inventory existing accepted addresses, organization/subdomain mappings, fallback destinations, and disabled sites. Backfill records explicitly; do not silently create a mailbox for every incoming address.
204
+ 2. Confirm existing Worker routing for one organization's exact domain and activate only verified addresses. Exercise an alias, unknown address, suspended mailbox, and cross-tenant destination with synthetic mail.
205
+ 3. Add request verification and preserve your current sender review, archive behavior, error responses, and size limits. Test that rejected or held mail cannot reach the processing queue.
206
+ 4. Connect site provisioning to the same mailbox API used by the [management engine](management-engine.md). Scope the engine through existing host authentication and authorization.
207
+ 5. Retire duplicate infrastructure only after comparing delivery and recovery behavior. Inbound adoption does not require switching your outbound provider.
208
+
209
+ Keep the shared operational communications history separate from tenant-local raw emails and mailbox membership when those serve different purposes. Define who owns deletion, archive retention, deduplication, and processing status; two competing delivery ledgers make recovery harder.
210
+
211
+ The gem's custom-ingress integration test exercises a custom controller against two real SQLite tenant databases, preserving binary MIME bytes and signed metadata before routing jobs run. It also verifies rejected metadata, host review, accepted-address enforcement, and retry behavior. It does not verify a deployed customer's Cloudflare DNS, Worker authentication evidence, R2 archive, or document-processing pipeline; those require that host's end-to-end tests.
@@ -2,6 +2,15 @@
2
2
 
3
3
  Cloudflare Email Sending can publish outbound lifecycle events to Queues. This is separate from inbound Email Routing and the ActionMailbox ingress.
4
4
 
5
+ Mail to a verified Email Routing destination can travel through Routing without
6
+ producing an Email Sending callback. Keep using the recipient's normal address;
7
+ do not interpret a missing callback as failure or resend the message. The optional
8
+ [Routing delivery confirmation](routing-deliveries.md) integration checks
9
+ authenticated analytics for qualifying single-recipient deliveries. It requires
10
+ a separate **Analytics Read + Zone Read** credential and leaves unsupported or
11
+ ambiguous evidence unresolved. Acceptance, receiving-server delivery and a human
12
+ reading the message are different facts.
13
+
5
14
  ## Provision once
6
15
 
7
16
  1. Create a dedicated Cloudflare Queue for your application's sending events.
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.2.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
 
@@ -29,6 +29,34 @@ a tenant connection adapter. A mailbox tenant key alone does not switch database
29
29
  | Observe email processing | ActiveSupport notifications + `doctor` | Send/ingress/event/outbox instrumentation and configuration diagnostics |
30
30
  | Deploy receiving infrastructure | Ruby deployer and routing tasks | Environment-specific Workers, ingress secrets, address routes and DNS preflight checks |
31
31
 
32
+ ## Integration tools
33
+
34
+ [Durable inbound delivery](../templates/worker/README.md#durable-inbound-delivery)
35
+ saves raw email and its envelope in R2 before the Worker returns successfully.
36
+ Queues attempt the Rails handoff, and a scheduled pass recovers retained mail
37
+ after enqueue failures or retry exhaustion. Provision the storage, queue and
38
+ schedule before deploying; direct forwarding requires an explicit fallback setting.
39
+
40
+ [Routing delivery confirmation](routing-deliveries.md) adds a read-only provider
41
+ client and optional durable receipts for qualifying single-recipient deliveries
42
+ that appear in Routing analytics. It does not infer outcomes from missing events
43
+ or change whether an accepted email may be sent again.
44
+
45
+ Existing ingestion pipelines can use [custom ingress](custom-ingress.md) to
46
+ verify bounded raw messages before tenant lookup, keep their own storage, or
47
+ persist through ActionMailbox. Optional v3 signatures authenticate custom Worker
48
+ metadata; your application still decides whether its provenance and sender policy
49
+ are acceptable. The bundled Worker continues to use v2 by default.
50
+
51
+ `Mailboxes.receive` yields the resolved destination while retaining email and
52
+ membership persistence, so your own processing record can link to the saved mail.
53
+ `Mailboxes.with_recipient` offers the same destination context for applications
54
+ that own their storage. Neither replaces your sender policy or business workflow.
55
+
56
+ [Read-only routing diagnostics](routing-diagnostics.md) inspect exact-domain DNS
57
+ and the selected Worker route. They distinguish missing configuration from an
58
+ incomplete inspection; a passing snapshot does not prove live email delivery.
59
+
32
60
  ## SQLite works
33
61
 
34
62
  The optional receipt and outbox tables work with **SQLite**. Keep your existing
@@ -58,7 +86,7 @@ subscriptions. The [getting-started guide](getting-started.md) walks through eac
58
86
 
59
87
  ## Protections included
60
88
 
61
- Incoming requests use v2 HMAC signatures covering the timestamp, SMTP sender,
89
+ By default, incoming requests use v2 HMAC signatures covering the timestamp, SMTP sender,
62
90
  SMTP recipient and raw message. Rails checks a five-minute timestamp window.
63
91
  Identical MIME retried for the same recipient is deduplicated; delivery to a
64
92
  different recipient is stored separately.
@@ -83,7 +111,9 @@ See [managed mailbox setup](mailboxes.md) for the application-facing API and
83
111
  The gem supplies email infrastructure and optional managed mailbox records.
84
112
  Your app supplies users, mailbox permissions, conversation records, custom folders, search, compose screens,
85
113
  drafts and any AI review or approval workflow. It also decides unsubscribe and
86
- recipient eligibility policy. The gem does not supply an inbox UI or AI agent.
114
+ recipient eligibility policy. The optional [management engine](management-engine.md)
115
+ adds server-rendered mailbox administration and plain-text message previews.
116
+ It does not supply a compose/conversation product or AI agent.
87
117
 
88
118
  An authenticated SMTP envelope tells your app which address Cloudflare received
89
119
  the message for. It does not prove the human sender's identity. Reply correlation
@@ -91,8 +121,9 @@ also does not authorize access to a conversation. Apply your app's permissions
91
121
  before displaying mail or sending a reply.
92
122
 
93
123
  You configure Cloudflare domains/DNS, queue subscriptions, durable jobs,
94
- monitoring, storage protection and retention. Inbound mail is not durably
95
- buffered by the Worker during a Rails outage. See [architecture](architecture.md)
124
+ monitoring, storage protection and retention. Direct forwarding does not buffer
125
+ Rails outages; the optional durable inbound setup retains pending mail in R2.
126
+ See [architecture](architecture.md)
96
127
  for the full boundary and [security guidance](../SECURITY.md) for deployment.
97
128
 
98
129
  ## Where to go next
@@ -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.2 to your app's `Gemfile`. If upgrading from 0.1, read the
22
- [upgrade guide](upgrading-0.2.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.2.0"
26
+ gem "cloudflare-email", "~> 0.4.0"
26
27
  ```
27
28
 
28
29
  ```sh
@@ -123,8 +124,18 @@ In Cloudflare **Email Routing → your apex domain → Settings → Subdomains**
123
124
  your receiving subdomain, such as `in.example.com`, and finish its DNS setup.
124
125
  This is separate from verifying the sending domain.
125
126
 
126
- Deploy Rails with ActionMailbox storage and workers configured. Then run these
127
- commands in an environment with production configuration and deployment credentials:
127
+ Deploy Rails with ActionMailbox storage and workers configured. For guided
128
+ Worker setup, use the [Deploy to Cloudflare template](../templates/deploy-to-cloudflare/README.md).
129
+ It provisions R2 and Queues and prompts for your Rails URL and shared secret.
130
+ Then select that Worker in your Email Routing rule as described in its guide.
131
+
132
+ For the CLI setup instead, run these commands in an environment with production
133
+ configuration and deployment credentials:
134
+
135
+ First create the private R2 bucket and Queue, then deploy the generated Wrangler
136
+ configuration using the [durable setup guide](../templates/worker/docs/durable-inbound.md).
137
+ The Ruby task below updates that provisioned Worker while preserving its bindings.
138
+ It refuses a missing bucket/queue binding or recovery schedule before changing code.
128
139
 
129
140
  ```sh
130
141
  RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
@@ -166,11 +177,11 @@ Install `cloudflared`, configure development credentials and a separate developm
166
177
  receiving route, then start Rails. In another terminal:
167
178
 
168
179
  ```sh
169
- bin/rails cloudflare:email:deploy_worker
180
+ INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
170
181
  bin/rails cloudflare:email:dev
171
182
  ```
172
183
 
173
- The first command creates the development Worker; the second points it at a
184
+ The first command creates the development Worker with the explicit direct fallback; the second points it at a
174
185
  temporary tunnel to your local Rails server on port 3000. Keep the tunnel running
175
186
  while testing and stop it with Ctrl-C. Set `PORT=...` if Rails uses another port.
176
187
  The task checks that Rails has the ingress-only guard; restart Rails if asked.
@@ -295,4 +306,4 @@ override it. Cloudflare has separate [outgoing size and recipient limits](https:
295
306
 
296
307
  Use [troubleshooting](troubleshooting.md) when something does not arrive,
297
308
  [the feature overview](features.md) to explore more, and
298
- [the upgrade guide](upgrading-0.2.md) for an existing 0.1 installation.
309
+ [the upgrade guide](upgrading-0.3.md) for an existing 0.1 installation.
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 in version 0.2.0. Install `gem "cloudflare-email", "~> 0.2.0"`.
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;
@@ -34,7 +34,8 @@ require "cloudflare/email/mailboxes"
34
34
  Loading this module enables mailbox lookup on the gem's Cloudflare ingress.
35
35
  Existing receiving addresses must be registered and activated before switching
36
36
  an existing application over. Unregistered or suspended destinations return
37
- HTTP 422 and the Worker rejects the delivery; there is no default-mailbox fallback.
37
+ HTTP 422 and the Worker rejects the delivery by default. You can explicitly
38
+ configure a domain catch-all below when your application needs that behavior.
38
39
 
39
40
  For separate organization databases, follow the
40
41
  [activerecord-tenanted setup](activerecord-tenanted.md) **before loading the
@@ -44,6 +45,13 @@ use the same tenant connection as the mailbox tables for atomic incoming storage
44
45
 
45
46
  ## Register an organization's domain
46
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
+
47
55
  Run directory management from your authorized administration/provisioning code.
48
56
  Do not expose arbitrary domain claims to customers without ownership checks.
49
57
 
@@ -146,6 +154,67 @@ are explicit: the module does not automatically strip `+tags` or invent address
146
154
  fallbacks. Reserve application-specific names such as Rebulk's `tracking` in
147
155
  your mailbox-management policy before creation.
148
156
 
157
+ ## Receive unregistered local parts with an optional catch-all
158
+
159
+ Catch-all receiving is **off by default** and is available in the 0.3
160
+ version. It lets one existing address receive otherwise unregistered addresses
161
+ on its exact domain, without creating an alias row for each incoming local part.
162
+ For example, `anything@acme.example.com` can arrive in an existing support mailbox.
163
+ It does not cover subdomains such as `anything@other.acme.example.com`.
164
+
165
+ Fresh installations include the schema. For an existing installation, generate
166
+ the optional upgrade and apply it to the database containing your mailbox tables:
167
+
168
+ ```sh
169
+ bin/rails generate cloudflare:email:mailboxes:catch_all
170
+ bin/rails db:migrate
171
+ ```
172
+
173
+ With separate tenant databases, pass
174
+ `--tenant-migrations-path=db/tenant_migrate` and run your application's tenant
175
+ migration command for every affected tenant. Updating the gem alone keeps older
176
+ schemas working with exact-address routing. Enabling a catch-all without its
177
+ migration raises a configuration error.
178
+
179
+ First configure and verify the domain's catch-all Email Routing rule in Cloudflare
180
+ to send mail to your Worker. Then use your authorized provisioning code:
181
+
182
+ ```ruby
183
+ Cloudflare::Email::Mailboxes.for_tenant("organization-123") do |account|
184
+ mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
185
+ address = account.addresses(mailbox.id).find_by!(address: "support@acme.example.com")
186
+ account.enable_catch_all(address.id,
187
+ evidence: "Domain catch-all Worker route verified in setup ticket 57")
188
+
189
+ # Later, stop accepting unregistered local parts:
190
+ # account.disable_catch_all(address.id)
191
+ end
192
+ ```
193
+
194
+ The address, mailbox and receiving domain must be active before enabling it.
195
+ The evidence is an operator assertion, as with ordinary address activation;
196
+ this call does not change Cloudflare rules. Only one active address per domain
197
+ can serve as a catch-all. A database constraint also protects concurrent changes.
198
+ Suspending that address pauses fallback. If another catch-all is enabled while
199
+ it is suspended, disable the old flag before reactivating the old address.
200
+
201
+ Exact registered addresses always take precedence. An exact address that is
202
+ pending, suspended, or belongs to a suspended mailbox is rejected; it does not
203
+ fall through to another mailbox. The catch-all also stops receiving when its
204
+ mailbox or domain is suspended.
205
+
206
+ Both `with_recipient` and `receive` yield `destination.catch_all == true` only
207
+ when fallback was used. `destination.recipient` remains the actual envelope
208
+ recipient, while `address_id` identifies the existing backing address. Stored
209
+ mailbox membership retains that actual recipient too. Apps can use this flag to
210
+ enforce additional rules, such as allowing fallback only while an organization
211
+ has one site, before accepting/persisting the message.
212
+
213
+ Catch-all receiving grants **no additional sending addresses**. Outbound From,
214
+ Sender and envelope sender must still match an exact active registered address.
215
+ Disable catch-all receiving through the API; the management UI displays its
216
+ configuration but does not enable or disable it.
217
+
149
218
  ## Read incoming mail
150
219
 
151
220
  The Worker signs the SMTP recipient. Rails verifies the whole request, resolves
@@ -294,6 +363,8 @@ lookup. Drain existing ActionMailbox/ActiveStorage queues before enabling this
294
363
  mode: previously serialized jobs without the new metadata are rejected.
295
364
 
296
365
  The gem does not create organizations/users, verify customer domain ownership,
297
- provide IMAP/POP, or supply an inbox UI. The original Rebulk document-processing
366
+ provide IMAP/POP, or supply a full compose/conversation inbox. The optional
367
+ [management engine](management-engine.md) supplies mailbox administration and
368
+ plain-text previews using your host's access policy. The original Rebulk document-processing
298
369
  Worker contract and sender-review policy still need a separate application
299
370
  migration; installing this module does not replace that live pipeline.
@@ -0,0 +1,190 @@
1
+ # Optional mailbox management engine
2
+
3
+ The management engine is a small, server-rendered Rails interface for the
4
+ [managed mailbox APIs](mailboxes.md). It works with ordinary Rails apps and with
5
+ apps using Inertia, React, Turbo or another frontend. No JavaScript, Node build,
6
+ asset pipeline or frontend package is required for the engine.
7
+
8
+ This feature is available in RubyGems 0.3.0.
9
+
10
+ ## What you get
11
+
12
+ - Mailbox lists, names, creation and aliases on allowed receiving domains.
13
+ - Address setup status and mailbox suspension/resumption.
14
+ - A paginated list of received messages and an escaped plain-text preview.
15
+ - Optional read/unread and archive/unarchive controls.
16
+ - Ordinary CSRF-protected forms, mobile layouts and bundled CSS.
17
+
18
+ Your app supplies login, permissions, domain entitlements and any organization
19
+ mapping. The engine does not create users, claim domains, edit DNS, provision
20
+ Cloudflare routes, send mail, or permanently delete records. The default creation
21
+ hooks create pending addresses. Your administrator or existing provisioning code
22
+ completes routing and activation through the same Ruby APIs.
23
+
24
+ The viewer never renders sender HTML, loads remote images, or provides raw MIME
25
+ or attachment downloads. It shows attachment names and up to 100,000 plain-text
26
+ characters. Compose, conversations, AI workflows and product search belong in
27
+ your application.
28
+
29
+ ## Install and mount
30
+
31
+ First install the managed mailbox tables and enable the module using
32
+ [the mailbox setup guide](mailboxes.md). SQLite works; database tenancy remains
33
+ off unless your app configures it explicitly.
34
+
35
+ Require the engine **before Rails initializes**, after Bundler loads the gem:
36
+
37
+ ```ruby
38
+ # config/application.rb, after Bundler.require(*Rails.groups)
39
+ require "cloudflare/email/management"
40
+ ```
41
+
42
+ Configure a host adapter in an initializer:
43
+
44
+ ```ruby
45
+ # config/initializers/cloudflare_email_management.rb
46
+ Cloudflare::Email::Management.configure do |config|
47
+ # Resolve the class when handling the request, so Rails reloading works.
48
+ config.adapter = ->(controller) { EmailManagementAccess.new(controller) }
49
+ config.back_path = ->(controller) { controller.main_app.root_path }
50
+ end
51
+ ```
52
+
53
+ Mount it wherever it fits your application:
54
+
55
+ ```ruby
56
+ # config/routes.rb
57
+ mount Cloudflare::Email::Management::Engine => "/email-management",
58
+ as: :email_management
59
+ ```
60
+
61
+ Installing the gem does not expose this interface automatically. Without a
62
+ configured adapter and enabled mailbox module the mounted interface returns 503.
63
+ The base adapter grants no access. Its public stylesheet endpoint serves only
64
+ the bundled CSS.
65
+
66
+ ## Connect your authentication and ownership
67
+
68
+ Here is a single-database example using Rails' generated authentication convention
69
+ (`Session`, `Current.session`, and a signed `session_id` cookie). Match the cookie
70
+ and session lookup to your app; the agentic inbox example uses `session_token`.
71
+ Use the host authentication system's expiry, revocation and access policy.
72
+
73
+ ```ruby
74
+ # app/services/email_management_access.rb
75
+ class EmailManagementAccess < Cloudflare::Email::Management::Adapter
76
+ ACTIONS = %i[index show create add_address suspend resume
77
+ show_message mark_read archive].freeze
78
+
79
+ def authenticate!
80
+ Current.session = Session.find_by(
81
+ id: controller.request.cookie_jar.signed[:session_id]
82
+ )
83
+ @user = Current.user
84
+ return true if @user
85
+
86
+ controller.redirect_to controller.main_app.new_session_path
87
+ false
88
+ end
89
+
90
+ def tenant_key = "application"
91
+ def owner_ref = "User:#{@user.id}"
92
+
93
+ def mailboxes(session)
94
+ session.mailboxes.where(owner_ref: owner_ref)
95
+ end
96
+
97
+ def allowed?(action, mailbox = nil)
98
+ @user.present? && ACTIONS.include?(action) &&
99
+ (mailbox.nil? || mailbox.owner_ref == owner_ref)
100
+ end
101
+
102
+ def domains(_session)
103
+ ["in.example.com"] # Only domains this user is entitled to use.
104
+ end
105
+
106
+ def create_mailbox(session, name:, address:)
107
+ session.create(name: name, address: address, owner_ref: owner_ref)
108
+ end
109
+ end
110
+ ```
111
+
112
+ For Devise, the authentication hook can use your configured Warden scope instead
113
+ of the cookie lookup, for example
114
+ `@user = controller.request.env["warden"]&.authenticate(scope: :user)`.
115
+ The engine inherits from `ActionController::Base`; host `ApplicationController`
116
+ callbacks and methods are not inherited. Make authentication explicit in the
117
+ adapter and return exactly `true` on success, or redirect to the host's login.
118
+
119
+ The directory must already contain the active receiving domain assigned to the
120
+ same registry key. The engine intersects `domains` with that directory and
121
+ intersects the host mailbox relation with the current gem tenant. Parameters
122
+ cannot select an owner or tenant.
123
+
124
+ Do not return every domain merely because it exists in the database. A customer
125
+ app should apply organization entitlement, reserved-name and signup policies.
126
+ For separate tenant databases, return a trusted host-resolved key from
127
+ `tenant_key` and configure the existing gem tenancy adapter and framework
128
+ connections before mailbox models load.
129
+
130
+ ## Customize behavior without forking the pages
131
+
132
+ The adapter has these hooks:
133
+
134
+ | Hook | Contract |
135
+ | --- | --- |
136
+ | `authenticate!` | Authenticate the request; return exactly true or redirect/deny. |
137
+ | `tenant_key` | Trusted registry key; never use an unchecked request parameter. |
138
+ | `mailboxes(session)` | ActiveRecord relation containing mailboxes accessible to this principal. |
139
+ | `allowed?(action, mailbox = nil)` | Return exactly true to permit an action. |
140
+ | `domains(session)` | Array of domain strings permitted for this principal. |
141
+ | `create_mailbox(session, name:, address:)` | Return the created gem mailbox, visible in the host's scope. |
142
+ | `add_address(session, mailbox, address:)` | Add an address through your existing app provisioning service if needed. |
143
+
144
+ The adapter factory, authentication and tenant-key resolution run before the
145
+ mailbox context is entered; the host must establish any context its authentication
146
+ needs. Mailbox/domain scopes, permissions and mutation hooks run inside the
147
+ resolved mailbox context. Creation also runs in a
148
+ database transaction; a returned mailbox outside the host scope rolls back.
149
+ Keep network work out of creation hooks and enqueue provisioning after commit.
150
+ The default alias hook calls `session.add_address`.
151
+
152
+ Return false for `:create` or lifecycle actions to provide a restricted dashboard.
153
+ Use `:show_message` to control message access independently of metadata. Deny
154
+ `:mark_read` and `:archive` when the host product already owns a different
155
+ conversation-level read/folder model. Hidden controls also reject direct requests.
156
+
157
+ An application with its own mailbox record can override creation and alias hooks
158
+ to call that application's service and return the linked gem model. The agentic
159
+ inbox example does this: both UIs and automatic account provisioning share one
160
+ service, so engine-created mailboxes also appear in its full inbox.
161
+
162
+ ## Inertia, React and Turbo applications
163
+
164
+ Use a normal HTML link or full-page navigation to the mount. For example:
165
+
166
+ ```jsx
167
+ <a href="/email-management">Manage mailboxes</a>
168
+ ```
169
+
170
+ The engine returns HTML, not Inertia responses. Its separate layout keeps host
171
+ frontend dependencies out of the gem. The optional `back_path` callback returns
172
+ a local absolute path for a Back to app link. Programmatic provisioning continues
173
+ to work whether this engine is mounted or not.
174
+
175
+ ## Operational behavior
176
+
177
+ Suspension preserves mailbox records and retained mail while disabling mailbox
178
+ sending/receiving. Resuming does not activate pending addresses or repair DNS.
179
+ Read/archive state belongs to the gem mailbox memberships and does not implicitly
180
+ rewrite host conversation records.
181
+
182
+ Private pages use no-store caching, a restrictive CSP and a same-origin referrer
183
+ policy. Forms check both Rails CSRF tokens and origins. Serve the host over HTTPS,
184
+ configure trusted proxy headers correctly, and keep its session cookies secure.
185
+ Do not disable CSRF to work around a proxy configuration issue.
186
+
187
+ Verification includes real Rails sessions, owner/tenant scoping, all operation
188
+ permissions, CSRF/origin checks, rollback, escaped previews, retained raw source,
189
+ and eager-loading a core-only app without exposing the engine. The example also
190
+ tests desktop/mobile Chromium and JavaScript-disabled forms with synthetic mail.