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,94 @@
1
+ # Confirm delivery to verified Routing destinations
2
+
3
+ Cloudflare can deliver mail to a verified Email Routing destination through Routing without producing an Email Sending delivery callback. You can keep using the recipient's normal address. Do not remove its verified destination or rewrite the recipient to obtain a callback.
4
+
5
+ The optional Routing Analytics integration confirms **positive delivery to the receiving mail server** from authenticated Cloudflare analytics. It does not prove inbox placement or that a person read the message. Missing analytics leave the outcome unresolved; they never authorize a resend or provider fallback.
6
+
7
+ ## Enable it
8
+
9
+ The plain Ruby client needs no Rails or Active Record dependency:
10
+
11
+ ```ruby
12
+ require "cloudflare/email/routing_analytics"
13
+
14
+ client = Cloudflare::Email::RoutingAnalytics::Client.new(
15
+ account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID"),
16
+ zone_id: ENV.fetch("CLOUDFLARE_ANALYTICS_ZONE_ID"),
17
+ api_token: ENV.fetch("CLOUDFLARE_ANALYTICS_TOKEN")
18
+ )
19
+ client.verify_account!
20
+ ```
21
+
22
+ Use a separate token with **Zone Analytics Read and Zone Read**, restricted to the sending zone. The client verifies that the zone belongs to the supplied account before querying analytics. Keep sending and queue credentials separate. A token is never written into a receipt.
23
+
24
+ For durable Rails tracking, install the outbox first, then generate the additional receipt table:
25
+
26
+ ```sh
27
+ bin/rails generate cloudflare:email:outbox # only if not already installed
28
+ bin/rails generate cloudflare:email:routing_tracking
29
+ bin/rails db:migrate
30
+ ```
31
+
32
+ Receipts encrypt their JSON evidence using Rails Active Record Encryption. Configure the application's `active_record.encryption.primary_key`, `deterministic_key`, and `key_derivation_salt` before recording receipts (Rails' `db:encryption:init` task generates keys). Keep keys in credentials or protected environment configuration and back them up separately. There is no plaintext fallback enabled by this integration. Receipt metadata includes account and operation identifiers; protect the database and backups as usual.
33
+
34
+ The generated initializer explicitly requires `cloudflare/email/active_record/routing_deliveries`. Installing the gem alone enables no scheduler, network request, tracking table, or tenancy behavior.
35
+
36
+ ## Collect and apply evidence
37
+
38
+ Run network requests outside database transactions. The durable outbox must have retained the accepted operation's SMTP envelope and provider Message-ID:
39
+
40
+ ```ruby
41
+ tracking = Cloudflare::Email::ActiveRecord::RoutingDeliveries
42
+
43
+ client.delivery_evidence(
44
+ message_id: delivery.provider_message_id,
45
+ since: delivery.request_started_at,
46
+ until_time: Time.now.utc
47
+ ).each do |evidence|
48
+ receipt = tracking.record(delivery: delivery, evidence: evidence)
49
+ tracking.apply(receipt) do |saved_delivery, recipient|
50
+ # Optional business projection using this same database connection.
51
+ # Read recipient.state; do not send mail or call external services here.
52
+ end
53
+ end
54
+ ```
55
+
56
+ `record` commits the original evidence before projection. Repeating it returns the same receipt. A changed authentic event under the same identity, or association with another operation, raises an error. Query windows and other rows returned alongside that event may differ between polls without creating duplicate receipts. Client payloads retain the complete original response envelope, provider row, authenticated account/zone, source, and query window and are immutable through ordinary model updates.
57
+
58
+ `apply` returns `:applied`, including successful replay. It updates only a nonterminal recipient and marks the receipt applied in the same transaction. It preserves the accepted operation, envelope snapshot, and every existing terminal webhook fact, including a bounce. Thus `:applied` means evidence was processed; inspect the recipient's actual state. A later genuine Sending complaint continues to update the recipient through the existing Sending event API.
59
+
60
+ Callbacks run only when the recipient changes. Same-database callback errors roll back the recipient, business writes, and receipt completion together; the original pending receipt remains available. A callback must not send email or perform external effects.
61
+
62
+ This is not a webhook endpoint: accept evidence only from the authenticated client or your protected durable storage. Constructing an `Evidence` object validates its shape and correlation, not its provenance. Never deserialize arbitrary incoming request bodies into trusted analytics evidence.
63
+
64
+ ## Retry projection fairly
65
+
66
+ ```ruby
67
+ result = tracking.replay(account_id: account_id, limit: 100, after_id: saved_cursor)
68
+ result.errors.each do |failure|
69
+ ErrorReporter.report(failure[:error], receipt_id: failure[:receipt_id])
70
+ end
71
+ save_cursor(result.finished ? 0 : result.after_id)
72
+ ```
73
+
74
+ The batch is bounded to 1–1,000 receipts. Failed receipts advance the cursor, allowing later receipts to proceed. After a complete pass, reset the cursor and revisit failures. Errors remain visible in the return value; your application must report them. Supply the same optional projection callback when replaying. Give fresh analytics discovery a separate scheduling opportunity so repeated projection failures cannot starve new evidence. No database transaction is interrupted by an HTTP or polling timeout.
75
+
76
+ The client uses a five-second total network deadline, five-second socket timeouts, a 1,000,000-byte streamed response cap and no implicit HTTP retry. A failed request is visible to your scheduler. Queries returning 100 rows are rejected as potentially truncated; use a narrower historical window and retain existing unresolved records. Plan availability, analytics retention and sampling can limit what Cloudflare can prove.
77
+
78
+ ## Deliberately conservative matching
79
+
80
+ The initial API requires exactly one accepted saved SMTP-envelope recipient, exactly one matching accepted operation in the account, matching sender and normalized provider Message-ID, and a nonempty provider session ID. The event must be `newEmail`, `delivered`, final, unsampled and not an NDR. Its timestamp must fall inside the requested historical window and at or after the saved request start, rounded down to Cloudflare's second precision.
81
+
82
+ MIME `To` and analytics `to` are never used as envelope identity. Multi-recipient operations, sampled rows, forwarding rows and missing events do not become guessed delivery or failure. Malformed, truncated, conflicting or wrongly scoped qualifying evidence raises. Terminal delivery can override an equal or later nonterminal observation; existing terminal facts stay intact.
83
+
84
+ ## Tenant and cross-database applications
85
+
86
+ Multi-tenancy remains off by default. If enabled, use the existing `Tenancy.with` or `Mailboxes.for_tenant` context for every outbox and Routing receipt operation, and install the receipt migration in each tenant database. Record instances cannot be moved between tenant contexts. A separate analytics account does not select or authorize a tenant.
87
+
88
+ The gem checks provider-ID uniqueness inside the selected database. If one Cloudflare account spans several tenant databases, the host must also enforce account-wide provider-ID uniqueness in its shared directory or ledger before calling `record`. Tenant-local lookup alone cannot establish that a matching operation is unique across independent databases.
89
+
90
+ An application may reserve an explicit physical storage key for system mail, such as `_system_outbound`, using its existing tenant provisioning and backup process. That storage key is not an organization or authorization identity. Keep nil tenant context fail-closed; do not silently map it to shared storage. Reserve and validate the key in the host application so a customer cannot claim it.
91
+
92
+ For business records on another database, keep the host's own durable intake/projection record. Commit the gem evidence and projection in the selected tenant database, then finish the host projection. If the second commit fails, replay the same gem evidence and finish the host work independently. The gem's callback will not run again after its receipt was applied. There is no distributed transaction or exactly-once external effect guarantee.
93
+
94
+ Cloudflare references: [Email event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/) and [metrics and analytics](https://developers.cloudflare.com/email-service/observability/metrics-analytics/).
@@ -0,0 +1,75 @@
1
+ # Check whether an address is configured to reach your Worker
2
+
3
+ This diagnostic is available in gem version 0.3.0.
4
+
5
+ A mailbox registered in Rails is not proof that Cloudflare can deliver to it.
6
+ Use this read-only check when onboarding a receiving domain, investigating a
7
+ missing message, or checking an existing catch-all before activating addresses.
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
+
16
+ ```sh
17
+ bin/rails cloudflare:email:check_route \
18
+ ADDRESS=houston@customer.example.com \
19
+ WORKER_NAME=cloudflare-email-ingress-production \
20
+ ACCOUNT_ID=your-cloudflare-account-id
21
+ ```
22
+
23
+ Configure your Cloudflare management token in the normal gem credentials. The
24
+ inspection needs Zone Read, DNS Read, and Email Routing Read permissions. No Edit
25
+ permissions are needed. `WORKER_NAME` defaults to the gem's environment-scoped
26
+ Worker name; `ACCOUNT_ID` falls back to the configured account, if present.
27
+
28
+ Every check reports `PASS`, `FAIL`, or `UNKNOWN`. The command exits zero only
29
+ when all configuration checks pass. An unavailable API, insufficient permission,
30
+ an unsupported matcher, or ambiguous rule ordering produces `UNKNOWN`, not a
31
+ successful result. Output excludes raw provider responses and credentials.
32
+
33
+ The check inspects:
34
+
35
+ - The closest containing Cloudflare zone and its account when an expected account is supplied.
36
+ - Whether Email Routing is enabled for the zone.
37
+ - MX and SPF records for the **exact receiving domain**. Parent-domain records
38
+ cannot satisfy a subdomain's requirements, and a parent's unrelated MX provider
39
+ does not conflict with correctly configured subdomain records.
40
+ - Active explicit address rules before falling back to the catch-all. An explicit
41
+ drop, forwarding action, or different Worker takes precedence over a correct
42
+ catch-all. Disabled explicit rules are ignored. Multiple matching rules or
43
+ unsupported matcher forms leave selection unverified rather than guessing
44
+ priority or tie behavior.
45
+
46
+ List reads are paginated and bounded to 50 pages per endpoint, responses to 1 MiB,
47
+ and each request to 20 seconds (with shorter connection/read timeouts). An
48
+ incomplete inspection does not report success. This command only issues GET
49
+ requests. It never enables routing, changes DNS, repairs rules, or activates
50
+ mailbox records.
51
+
52
+ ## Use from Ruby
53
+
54
+ ```ruby
55
+ require "cloudflare/email/routing_diagnostics"
56
+
57
+ report = Cloudflare::Email::RoutingDiagnostics.new(
58
+ api_token: ENV.fetch("CLOUDFLARE_API_TOKEN")
59
+ ).check(
60
+ address: "houston@customer.example.com",
61
+ worker_name: "cloudflare-email-ingress-production",
62
+ account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID")
63
+ )
64
+
65
+ report[:status] # "pass", "fail", or "unknown"
66
+ report[:checks] # [{ name: "zone", status: "pass", message: "..." }, ...]
67
+ ```
68
+
69
+ This API does not require Active Record or enable multi-tenancy. You can use it
70
+ with a custom Worker and an existing application ingestion pipeline. It reports
71
+ a provider configuration snapshot; it does **not** prove public DNS propagation,
72
+ full SPF evaluation, successful live delivery, the Worker's application URL or
73
+ behavior, or application acceptance. Keep those as separate checks, including a
74
+ controlled end-to-end delivery when appropriate. A passing report does not
75
+ automatically change mailbox readiness or constitute delivery evidence.
@@ -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. |
@@ -30,15 +30,15 @@ FROM=hello@mail.example.com TO=you@example.net bin/rails cloudflare:email:send_t
30
30
  | --- | --- |
31
31
  | Address receives nothing | Verify receiving-subdomain onboarding and the address route's Worker. Sending-domain verification is separate. |
32
32
  | Route provisioning refuses a subdomain | Complete Email Routing subdomain setup and DNS preflight first. Do not enable or replace apex routing just to bypass the error. |
33
- | Worker reports 401 from Rails | Check matching ingress secrets and that Rails and Worker both use the v2 code. Upgrade both together. |
33
+ | Worker reports 401 from Rails | Check matching ingress secrets and compatible signature versions. The Worker defaults to v2. The opt-in v3 [custom-ingress API](custom-ingress.md) requires upgrading Rails before enabling Worker metadata. |
34
34
  | Worker reports 408 | Check clock accuracy and the five-minute signing window. |
35
35
  | Worker reports 413 or rejects size | Raw MIME exceeds the configured limit. Match `MAX_EMAIL_BYTES` on Rails and Worker and check upstream request limits. |
36
36
  | Worker reports 3xx | Point it directly at the final HTTPS ingress URL; redirects are intentionally rejected. |
37
- | Worker times out | Check Rails availability and request latency. Its upstream request limit is 15 seconds; there is no durable inbound buffer. |
37
+ | Worker times out | Check Rails availability and request latency. Direct mode can reject mail; the default [durable inbound path](../templates/worker/README.md#durable-inbound-delivery) retains it in R2 for retry and scheduled recovery. |
38
38
  | Rails returns 200 but your product shows no message | Check ActionMailbox records, job workers, failed routing jobs and your mailbox's `process` method. The default mailbox only logs receipt. |
39
39
  | Retrying creates no new inbound record | Identical MIME for the same SMTP recipient is intentionally deduplicated. |
40
40
  | Bcc message appears routed to the wrong mailbox | Use `Envelope.for(inbound_email)["to"]` after checking the envelope exists; MIME `To` does not identify every SMTP recipient. |
41
- | Quoted or internationalized addresses are rejected | The v2 envelope currently supports ASCII dot-atom addresses, not every possible email address syntax. |
41
+ | Quoted or internationalized addresses are rejected | Both v2 and v3 envelopes support ASCII dot-atom addresses, not every possible email address syntax. |
42
42
 
43
43
  ## Local development
44
44
 
@@ -85,6 +85,7 @@ the actor you supply but does not authenticate that person.
85
85
  | What you see | What to check next |
86
86
  | --- | --- |
87
87
  | Queue stays empty | Check the Email Sending subscription, sending domain, selected event types and a real send from that domain. |
88
+ | A verified Routing destination received mail but Sending feedback is missing | Use optional [Routing delivery confirmation](routing-deliveries.md) with its separate read credential. Missing evidence is unresolved, not proof of failure; keep the normal recipient address. |
88
89
  | Polling fails before processing | Check the queue ID, separate Queues Read/Write token, HTTP pull configuration and configured event handler. |
89
90
  | Events process once then stop | `consume_events` is a one-batch task. Schedule it to run repeatedly. |
90
91
  | Events stay unmatched | Check account, normalized provider message ID and recipient. Schedule replay; a receipt can arrive before the send result is saved. |
@@ -26,6 +26,11 @@ Pause test ingress while updating Rails and the deployed Worker together; verify
26
26
  the matching ingress URL and shared secret before resuming delivery. There is no
27
27
  legacy routing mode or supported mixed-version rollout.
28
28
 
29
+ The 0.3 custom-ingress additions preserve v2 as the default and add
30
+ opt-in v3 for authenticated Worker metadata. That additive upgrade is separate
31
+ from the v1-to-v2 break above: upgrade the Rails receiver before enabling v3 in
32
+ your custom Worker. See [custom ingestion](custom-ingress.md).
33
+
29
34
  Rails stores authenticated metadata before enqueueing routing jobs.
30
35
  `Cloudflare::Email::Envelope.for(inbound_email)` returns a string-keyed `from`/`to`
31
36
  hash for accepted ingress. It returns `nil` for records from another ingress or
@@ -0,0 +1,55 @@
1
+ # Upgrade from 0.2 to 0.3
2
+
3
+ Update your Gemfile to `gem "cloudflare-email", "~> 0.3.0"`, run
4
+ `bundle update cloudflare-email`, and deploy Rails first. Updating the gem does
5
+ not replace your deployed Worker, change DNS, or enable optional mailbox features.
6
+ If you are upgrading from 0.1, complete the [0.2 upgrade](upgrading-0.2.md) too.
7
+
8
+ ## Receiving mail while Rails is down
9
+
10
+ New installations can use the [Deploy to Cloudflare template](../templates/deploy-to-cloudflare/README.md)
11
+ to provision the Worker resources through Cloudflare's setup form. Existing
12
+ installations should preserve their resources and pending mail using the steps below.
13
+
14
+ The bundled Worker now stores inbound mail in private R2 before accepting it,
15
+ then delivers through a Queue with scheduled recovery. Before deploying it:
16
+
17
+ 1. Create an R2 bucket and Queue for each environment.
18
+ 2. Configure the `INBOUND_EMAIL_STORE` binding, `INBOUND_EMAIL_QUEUE` producer
19
+ and consumer, and once-per-minute cron from the bundled `wrangler.toml`.
20
+ 3. Preserve your ingress URL and shared secret. Deploy with Wrangler first;
21
+ the Rails deploy task does not provision infrastructure.
22
+ 4. Verify that mail queues during a Rails outage, drains after recovery, and
23
+ retries without duplicating application records. Monitor pending mail and age.
24
+
25
+ Follow the [durable delivery guide](../templates/worker/docs/durable-inbound.md)
26
+ for commands, retention requirements, capacity limits, and recovery behavior.
27
+ Missing infrastructure fails visibly. To deliberately retain single-attempt
28
+ delivery, explicitly select `INBOUND_DELIVERY_MODE=direct` before deploying.
29
+ Direct mode cannot buffer outages. Keep existing bindings and cron so previously
30
+ stored mail continues to drain. The old `DURABLE_INBOUND_ENABLED` flag is removed.
31
+
32
+ Messages without a parseable Message-ID now receive a deterministic fallback
33
+ across Rails hosts. Historical records created with the old hostname-dependent
34
+ fallback may duplicate on replay; reconcile old archives before replaying them.
35
+ Retain deduplication records for your recovery horizon and make app processing
36
+ idempotent. Durable transport is not an exactly-once delivery guarantee.
37
+
38
+ ## Optional features
39
+
40
+ - [Management engine](management-engine.md): mount a server-rendered mailbox UI
41
+ with host authentication, scopes, and permissions. It is not mounted automatically.
42
+ - [Mailboxes](mailboxes.md): enable domain catch-all explicitly on an existing
43
+ active address. Follow the catch-all schema upgrade instructions; exact
44
+ registered addresses still take precedence, and sending remains exact-address only.
45
+ - [Custom ingestion](custom-ingress.md): integrate verified envelopes and raw
46
+ persistence into an existing app. Deploy Rails before enabling v3 metadata
47
+ signatures in a custom Worker; ordinary v2 forwarding remains supported.
48
+ - [Routing delivery confirmation](routing-deliveries.md): configure separate
49
+ analytics credentials and durable receipts if needed. API acceptance alone
50
+ is not proof of delivery, and ambiguous evidence must not trigger a resend.
51
+
52
+ Database tenancy remains opt-in. Existing tenant-enabled applications can now
53
+ preload their schemas without selecting a tenant; actual mailbox access still
54
+ requires explicit tenant context. No new database tables are required merely to
55
+ upgrade the gem or use the durable inbound Worker.
@@ -0,0 +1,49 @@
1
+ # Custom ingress and routing diagnostics verification
2
+
3
+ Scope: upstream gem integration tools informed by Rebulk's existing ingestion
4
+ pipeline. This report covers local synthetic verification, not a Rebulk rollout.
5
+
6
+ ## Results
7
+
8
+ | Check | Result |
9
+ | --- | --- |
10
+ | Full Ruby suite, Ruby 3.4.1 / Rails 7.2, 8.0, 8.1 | Each: 257 tests, 916 assertions, no failures/errors/skips |
11
+ | Shared ingress unit tests | 12 tests, 98 assertions; independent Node/Ruby v3 binary/Unicode vector, tampering, metadata bounds, short streams, immutable results, retry identity |
12
+ | Custom Rails endpoint | 5 integration tests, 37 assertions; separate SQLite tenant databases, policy before persistence, durable signed metadata and retries |
13
+ | Built-in Rails endpoint | V3 persistence before routing enqueue, binary preservation, retries and unsigned v2 metadata rejection; covered in the full suite |
14
+ | Read-only routing diagnostics | 27 tests, 50 assertions; mocked Cloudflare configuration, exact-domain DNS, rule selection, ambiguous/inaccessible responses and pagination |
15
+ | Worker | 45 tests pass; Wrangler dry-run succeeds |
16
+ | Packaged consumer | Isolated plain Ruby install and packaged Rails installation/ingress fixtures pass |
17
+ | Local workerd → Rails | Real local forwarding, Bcc routing, attachment preservation, wrong/missing secrets, redirects and timeout handling pass |
18
+
19
+ Independent reviews found two verifier issues: a readable stream can return
20
+ short chunks, and HMAC must use the timestamp's exact transmitted bytes. Both
21
+ were corrected and covered by regression tests. Freshness still parses decimal
22
+ seconds; existing bundled Worker timestamps are unchanged.
23
+
24
+ ## Reproduce
25
+
26
+ ```sh
27
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test
28
+ BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake test
29
+ BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rake test
30
+ bundle exec ruby script/verify_package.rb
31
+ BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle exec ruby script/verify_local_ingress.rb
32
+ npm --prefix templates/worker test
33
+ npm --prefix templates/worker run check
34
+ ```
35
+
36
+ Use Node 22 for the Worker and local forwarding checks.
37
+
38
+ ## Limits
39
+
40
+ No Rebulk application code, live DNS, Worker deployment, inbox activation, R2
41
+ archive, or gem publication was changed. The local workerd test exercises the
42
+ default v2 transport; opt-in v3 is covered by Worker tests, an independent shared
43
+ signature vector, and real Rails integration tests, not deployed Cloudflare mail.
44
+
45
+ Routing diagnostics inspect provider configuration through mocked API tests.
46
+ A passing configuration snapshot does not establish public DNS propagation,
47
+ complete SPF evaluation, Worker application behavior, or live delivery. Signed
48
+ metadata authenticates the forwarding integration's assertions; the host still
49
+ owns original-sender trust, held-message policy, and business processing.
@@ -0,0 +1,46 @@
1
+ # Destination context and email persistence verification
2
+
3
+ Scope: standalone accepted-address resolution and destination context yielded
4
+ from the existing persisted receive path. No Rebulk deployment or gem release.
5
+
6
+ ## Results
7
+
8
+ - Full suites on Ruby 3.4.1 with Rails 7.2, 8.0 and 8.1: each passed with
9
+ 257 top-level tests and 916 assertions. These include subprocess integration
10
+ fixtures whose internal assertions are not included in that top-level count.
11
+ - The standalone service fixture uses two real SQLite tenant databases with
12
+ colliding mailbox IDs and no ActionMailbox loaded. It verifies host-owned
13
+ binary-email persistence, immutable destinations, aliases, inactive records,
14
+ nested context restoration, host exceptions and missing-tenant refusal without
15
+ creating a database. Lookup itself creates no mailbox membership.
16
+ - The custom Rails ingress fixture verifies saved raw MIME and signed metadata,
17
+ mailbox membership, and a host business link created from the destination.
18
+ Routing jobs execute in the correct tenant after commit. Retries do not
19
+ duplicate the linked records. A host write failure rolls back the email and
20
+ membership and schedules no routing job.
21
+ - Existing callbacks remain supported, including strict zero-argument lambdas.
22
+ - Packaged plain Ruby and Rails consumer checks pass, including mailbox ingress,
23
+ routing and existing retention behavior.
24
+ - Independent review found no remaining blockers. A callback arity compatibility
25
+ issue was fixed and covered before completion.
26
+
27
+ ## Reproduce
28
+
29
+ ```sh
30
+ BUNDLE_GEMFILE=gemfiles/rails_7_2.gemfile bundle exec rake test
31
+ BUNDLE_GEMFILE=gemfiles/rails_8_0.gemfile bundle exec rake test
32
+ BUNDLE_GEMFILE=gemfiles/rails_8_1.gemfile bundle exec rake test
33
+ bundle exec ruby script/verify_package.rb
34
+ ```
35
+
36
+ ## Boundaries
37
+
38
+ Destination context is a lifecycle snapshot, not host authorization or a durable
39
+ processing claim. Applications still validate owners and sender policy and make
40
+ downstream work idempotent. The host owns custom-store transactions; `receive`
41
+ preserves the existing shared tenant transaction for ActionMailbox persistence.
42
+ Neither API makes cross-database or external object-store operations atomic.
43
+
44
+ Managed mailbox memberships protect raw email from automatic incineration;
45
+ deliberate purge, backups and host-reference cleanup remain application policy.
46
+ These local tests do not verify Rebulk's actual queues, sender policy or archive.
@@ -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.
@@ -0,0 +1,71 @@
1
+ # Durable inbound and Routing delivery verification
2
+
3
+ This change brings the verified Routing-delivery correlation from Rebulk into an
4
+ optional gem API and adds inbound storage/recovery for Rails outages. All tests
5
+ below use synthetic messages and disposable local storage. This report does not
6
+ claim the new Worker configuration is deployed in Rebulk production.
7
+
8
+ ## Boundaries verified
9
+
10
+ - Shared sending/queue HTTP requests stream bounded responses, including gzip
11
+ expansion, and enforce a total network deadline. Real socket tests cover
12
+ slow streams, socket cleanup, oversized replies and uncertain queue ACKs.
13
+ No ambiguous failure automatically resends an accepted email.
14
+ - Authenticated Routing analytics validates account/zone, provider ID, sender,
15
+ request window and a single saved envelope recipient. Dedicated encrypted
16
+ receipts preserve provider evidence and support idempotent projection.
17
+ - Inbound R2 persistence finishes before successful Email Worker return. Queue
18
+ messages contain only a storage pointer. Failed enqueue, Rails errors, lost
19
+ responses, failed deletion and exhausted retries retain the pending payload.
20
+ Scheduled pagination advances over poison messages and retries retained mail.
21
+ - A real local workerd instance uses R2 and Queues against Rails with SQLite and
22
+ disk-backed Active Storage. With queue retries disabled, Rails 503 leaves mail
23
+ in R2; invoking the scheduled handler after recovery persists one message and
24
+ removes the pending object. Returning 503 after Rails commits also replays to
25
+ one record. MIME and binary attachments remain byte-identical.
26
+ - Missing Message-ID fallback remains stable when Rails hostnames change. Valid
27
+ Message-IDs and raw MIME are preserved. Pre-upgrade fallback IDs require care
28
+ when replaying historical archive records.
29
+ - Local workerd capacity checks pass for one 25 MiB message and two concurrent
30
+ 25 MiB receipts, followed by concurrent handoffs. The receiver verifies exact
31
+ SHA256 and HMAC values, and R2 pending objects clear after acknowledgement.
32
+ Local workerd does not certify production memory-limit enforcement.
33
+
34
+ ## Reproduce
35
+
36
+ Use Ruby 3.4 and Node 22 or another supported version:
37
+
38
+ ```sh
39
+ BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle install
40
+ npm ci --prefix templates/worker
41
+ BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle exec rake test
42
+ npm test --prefix templates/worker
43
+ npm run check --prefix templates/worker
44
+ node script/verify_durable_capacity.mjs
45
+ BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle exec ruby script/verify_local_ingress.rb
46
+ BUNDLE_GEMFILE=gemfiles/local_ingress.gemfile bundle exec ruby script/verify_package.rb
47
+ ```
48
+
49
+ The local runtime harness invokes the real scheduled handler through a temporary
50
+ test-only endpoint. It does not test Cloudflare's production cron scheduler or
51
+ SMTP delivery behavior. No test endpoint is shipped in the Worker.
52
+
53
+ ## Before production enablement
54
+
55
+ Provision a private R2 bucket, queue and schedule per environment, and keep
56
+ pending objects free of lifecycle expiration. Deploy the updated Rails ingress
57
+ first. Verify maximum message size and recovery throughput with expected traffic,
58
+ then perform a staging outage drill through the actual Cloudflare email route.
59
+ Monitor storage errors and pending object age as well as queue backlog. Keep
60
+ Rails deduplication records throughout the recovery horizon and monitor mailbox
61
+ routing jobs after durable ingestion.
62
+
63
+ Routing tracking is separately opt-in and needs encryption keys, its migration,
64
+ a restricted analytics read credential, and host scheduling. Cross-database
65
+ applications must retain their durable business projection boundary and enforce
66
+ provider-ID uniqueness across tenant databases. Missing delivery evidence stays
67
+ unresolved; it never authorizes another send.
68
+
69
+ R2 storage and retry protect mail after a successful storage commit. They cannot
70
+ promise absolute losslessness during upstream storage failure, deletion,
71
+ misconfiguration or account suspension. See the [operating guide](../../templates/worker/docs/durable-inbound.md).
@@ -1,48 +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
- # Uses the host's abstract tenant connection owner when explicitly configured.
8
- class Base < Tenancy.model_base(::ActiveRecord::Base)
9
- self.abstract_class = true
10
-
11
- class << self
12
- def connection_pool
13
- Tenancy.require_context! if Tenancy.enabled?
14
- super
15
- end
16
- end
17
-
18
- before_validation :verify_cloudflare_email_tenant!
19
- before_save :verify_cloudflare_email_tenant!
20
- before_destroy :verify_cloudflare_email_tenant!
21
-
22
- # These methods can bypass callbacks or load a different row with the same ID.
23
- %i[reload update_columns delete touch increment! decrement! association].each do |method_name|
24
- define_method(method_name) do |*args, **kwargs, &block|
25
- verify_cloudflare_email_tenant!
26
- super(*args, **kwargs, &block)
27
- end
28
- end
29
-
30
- private
31
-
32
- # Both new construction and persisted-row instantiation call this before
33
- # assigning inverse associations (which precede after_initialize).
34
- def init_internals
35
- super
36
- @cloudflare_email_tenant_key = Tenancy.require_context! if Tenancy.enabled?
37
- end
38
-
39
- def verify_cloudflare_email_tenant!
40
- return unless Tenancy.enabled?
41
- unless @cloudflare_email_tenant_key == Tenancy.require_context!
42
- raise ConfigurationError, "record belongs to a different tenant context"
43
- end
44
- end
45
- end
6
+ Base = MailboxKit::ActiveRecord::Base
7
+ TenantConnectionUnavailable = MailboxKit::ActiveRecord::TenantConnectionUnavailable
46
8
  end
47
9
  end
48
10
  end