cloudflare-email 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. data/lib/cloudflare/email/secure_message_id.rb +0 -89
@@ -0,0 +1,186 @@
1
+ # Delivery events
2
+
3
+ Cloudflare Email Sending can publish outbound lifecycle events to Queues. This is separate from inbound Email Routing and the ActionMailbox ingress.
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
+
14
+ ## Provision once
15
+
16
+ 1. Create a dedicated Cloudflare Queue for your application's sending events.
17
+ 2. In that queue's **Subscriptions** tab, subscribe to Email Sending events for your verified sending domain. Subscriptions are domain-specific; add another for each sending domain you need.
18
+ 3. Select delivered, deferred, bounced, failed, rejected, and complained events.
19
+ 4. Enable **HTTP pull** as the queue consumer. Do not also attach a Worker push consumer.
20
+ 5. Configure queue retries and a dead-letter queue so failed or unsupported events can be investigated instead of disappearing after retry exhaustion.
21
+ 6. Create an account-scoped **Queues Read + Write** token (dashboard Queues Edit). Writing is necessary to acknowledge messages.
22
+
23
+ These are explicit Cloudflare setup steps, not actions performed by installing this gem. See [subscription management](https://developers.cloudflare.com/queues/event-subscriptions/manage-event-subscriptions/) and [HTTP pull consumers](https://developers.cloudflare.com/queues/configuration/pull-consumers/).
24
+
25
+ ## Rails setup
26
+
27
+ Add credentials:
28
+
29
+ ```yaml
30
+ cloudflare:
31
+ account_id: your-account-id
32
+ queues_token: your-queues-read-write-token
33
+ event_queue_id: your-queue-id
34
+ ```
35
+
36
+ Environment alternatives are `CLOUDFLARE_ACCOUNT_ID`, `CLOUDFLARE_QUEUES_TOKEN`, and `CLOUDFLARE_EVENT_QUEUE_ID`. Queue consumption uses its own token; it does not use the management-token fallback.
37
+
38
+ Configure a callable application handler:
39
+
40
+ ```ruby
41
+ # config/initializers/cloudflare_delivery_events.rb
42
+ Rails.application.configure do
43
+ config.x.cloudflare_email.event_domains = ["mail.example.com"]
44
+ config.x.cloudflare_email.event_handler = ->(event) {
45
+ DeliveryEventProcessor.call(event)
46
+ }
47
+ end
48
+ ```
49
+
50
+ `DeliveryEventProcessor` supplies application correlation and policy. For Rails,
51
+ use the optional durable receipt adapter below to avoid implementing receipt
52
+ storage, deduplication and replay yourself. Custom handlers must persist durably
53
+ before returning; do not merely enqueue a non-durable job.
54
+
55
+ ### Durable Rails receipts
56
+
57
+ If you also use the [durable outbox](outbox.md), prefer `DeliveryEvents.record`
58
+ and `DeliveryEvents.replay`: they provide account/message/recipient correlation
59
+ and state ordering automatically. The lower-level example below supports an
60
+ application's own delivery schema.
61
+
62
+ ```sh
63
+ bin/rails generate cloudflare:email:tracking
64
+ bin/rails db:migrate
65
+ ```
66
+
67
+ The generator installs a receipt table and an initializer that explicitly loads
68
+ `cloudflare/email/active_record/event_inbox`. ActiveRecord is not loaded by the
69
+ plain Ruby client. Configure the queue handler to commit receipts:
70
+
71
+ ```ruby
72
+ Rails.application.config.x.cloudflare_email.event_handler = ->(event) {
73
+ Cloudflare::Email::ActiveRecord::EventInbox.record(event)
74
+ }
75
+ ```
76
+
77
+ Run replay from your recurring job and after saving a send's provider ID:
78
+
79
+ ```ruby
80
+ Cloudflare::Email::ActiveRecord::EventInbox.replay(account_id: account_id) do |event|
81
+ # Your own model; always scope by account, message AND recipient.
82
+ delivery = RecipientDelivery.find_by(
83
+ account_id: event.account_id,
84
+ provider_message_id: Cloudflare::Email::MessageId.normalize(event.message_id),
85
+ recipient: event.recipient,
86
+ )
87
+ next :unmatched unless delivery
88
+
89
+ delivery.with_lock do
90
+ if event.supersedes?(occurred_at: delivery.occurred_at, terminal: delivery.terminal?)
91
+ delivery.update!(status: event.status, occurred_at: event.occurred_at,
92
+ terminal: event.terminal?)
93
+ end
94
+ end
95
+ :applied
96
+ end
97
+ ```
98
+
99
+ `record` stores the raw event and enforces uniqueness by account/event ID. An ID
100
+ repeated with different content raises. Recording inside an existing database
101
+ transaction raises, because returning before that transaction commits would allow
102
+ premature acknowledgment. An acknowledged receipt can still be pending or unmatched;
103
+ monitor and schedule replay separately from queue polling.
104
+
105
+ `apply(receipt) { |event| ... }` and `replay` lock each receipt and skip applied
106
+ records. Handlers must return `:applied` or `:unmatched`; exceptions and other return
107
+ values roll back the receipt transition and writes on the same database connection.
108
+ `:unmatched` is a successful transaction outcome, so avoid business writes before
109
+ returning it. External effects and writes to other databases cannot be rolled back.
110
+ Concurrent database conflicts raise; retry the job. Do not send email inside this
111
+ handler; use durable outbound orchestration.
112
+
113
+ Replay accepts optional `account_id:`, normalized provider `message_id:`, and
114
+ `batch_size:` filters. The batch size controls database fetch size, not the total
115
+ number processed. `DeliveryEvent#supersedes?` accepts a Time or ISO8601 previous
116
+ timestamp: only a newer known status replaces current state, equal timestamps keep
117
+ existing state, and a nonterminal event cannot replace terminal state. A later
118
+ terminal complaint can replace delivery. Invalid timestamps raise. Match records
119
+ and lock recipient state before using this ordering helper.
120
+
121
+ Receipt retention, recurring jobs, and monitoring are application decisions. See
122
+ the [gem/inbox boundary](architecture.md) for outbound-ledger scope. Existing
123
+ receipt tables from the earlier preproduction adapter must normalize stored
124
+ `message_id` values with `MessageId.normalize` before using filtered replay;
125
+ raw payloads remain unchanged. The reference inbox's outbox migration does this.
126
+
127
+ Poll a single batch:
128
+
129
+ ```sh
130
+ bin/rails cloudflare:email:consume_events
131
+ BATCH_SIZE=20 bin/rails cloudflare:email:consume_events
132
+ ```
133
+
134
+ Run the task from your existing scheduler, or invoke `EventConsumer#poll` from a recurring job. It short-polls once and exits; an empty queue is a successful result. No handler means an error before any queue request, not automatic discard.
135
+
136
+ For more control, use the API directly:
137
+
138
+ ```ruby
139
+ consumer = Cloudflare::Email::EventConsumer.new(
140
+ account_id: Cloudflare::Email::Credentials.account_id,
141
+ api_token: Cloudflare::Email::Credentials.fetch(:queues_token),
142
+ queue_id: Cloudflare::Email::Credentials.fetch(:event_queue_id),
143
+ domains: ["mail.example.com"],
144
+ )
145
+
146
+ count = consumer.poll(batch_size: 5, visibility_timeout_ms: 300_000) do |event|
147
+ DeliveryEventProcessor.call(event)
148
+ end
149
+ ```
150
+
151
+ The same API works in plain Ruby with explicit credentials.
152
+
153
+ ## Event data
154
+
155
+ | Method | Meaning |
156
+ |---|---|
157
+ | `event_id` | Durable application deduplication key |
158
+ | `message_id` | Cloudflare message ID for correlation with the send response |
159
+ | `status` | delivered, deferred, bounced, failed, rejected, complained |
160
+ | `known?` | Whether status is one of those six current types |
161
+ | `terminal?` | Cloudflare's terminal flag; not a guarantee no later complaint arrives |
162
+ | `sender`, `recipient` | Addresses from the event |
163
+ | `account_id`, `domain` | Event source, checked against consumer configuration |
164
+ | `occurred_at` | Original event timestamp string |
165
+ | `delivery`, `bounce`, `rejection`, `failure`, `complaint` | Detail hashes |
166
+ | `raw`, `payload`, `type` | Original provider data |
167
+
168
+ Record outcomes per message **and recipient**. A delivery event means the recipient's server accepted the message, not that the person read it. Use bounce/complaint events to maintain your application's recipient eligibility and unsubscribe state. Cloudflare's own suppression behavior remains controlled by Cloudflare; the consumer does not automatically edit provider suppression lists or your database.
169
+
170
+ Events can be duplicated or arrive out of order. Do not blindly overwrite a newer terminal state with an older deferred event. Decide how your application handles a complaint after delivery. Provider IDs may be absent from older send responses; retain event data even when an application record cannot yet be found.
171
+
172
+ ## Processing guarantees
173
+
174
+ For each message, the consumer decodes the body, validates the event, invokes your handler, and then acknowledges its lease. Email Sending subscriptions can return plain JSON strings with `CF-Content-Type: json`; the consumer also accepts base64-encoded JSON for that content type. `bytes` bodies contain base64-encoded JSON, while `text` bodies contain JSON directly. Invalid bodies and events remain unacknowledged.
175
+
176
+ - A handler that returns normally counts as successful, even if its return value is `false` or `nil`. **Raise** to prevent acknowledgement.
177
+ - A parse error, mismatched account/domain, handler exception, or acknowledgement failure stops the batch. Previously acknowledged messages remain acknowledged; the current and remaining leases become available after the visibility timeout.
178
+ - If the handler commits and acknowledgement fails, the event can be processed again. This is why the handler needs durable idempotency.
179
+ - Automatic HTTP retries are disabled for queue calls. Your scheduler can retry the next poll.
180
+ - The default batch size is 5 (max 100); the default visibility window is five minutes. Choose a window longer than the **whole batch's** worst-case processing time, up to Cloudflare's 12-hour maximum.
181
+ - Unknown future status names in schema version 1 are passed through with `known? == false`. Decide whether to store or raise. Unsupported schema versions are rejected and left unacknowledged.
182
+ - Configure dead-letter retention/alerts. Repeated failure eventually exhausts the queue's retry policy.
183
+
184
+ The `cloudflare_email.delivery_event` notification wraps handler execution with event ID, message ID, and status. It does not mean acknowledgement succeeded. Monitor task/job failures as well.
185
+
186
+ Source schemas: [Email Sending event subscriptions](https://developers.cloudflare.com/email-service/platform/event-subscriptions/), [pull API](https://developers.cloudflare.com/api/resources/queues/subresources/messages/methods/pull/), [ack API](https://developers.cloudflare.com/api/resources/queues/subresources/messages/methods/ack/).
data/docs/features.md ADDED
@@ -0,0 +1,138 @@
1
+ # What cloudflare-email does
2
+
3
+ cloudflare-email connects your Ruby or Rails application to Cloudflare's email
4
+ services. You can use just the sending client, add incoming mail, or build a
5
+ mailbox on top of the optional database-backed delivery tools.
6
+
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**:
9
+ the optional mailbox module works in one database unless you explicitly configure
10
+ a tenant connection adapter. A mailbox tenant key alone does not switch databases.
11
+
12
+ ## Choose the pieces you need
13
+
14
+ | You want to… | Use | What you get |
15
+ | --- | --- | --- |
16
+ | Create managed inboxes in code | Optional `Mailboxes` module | Named mailboxes, aliases, ownership references, read/archive state and retained raw mail |
17
+ | Isolate organizations in separate databases | Optional `Tenancy` adapter | Configurable ActiveRecord base, tenant-aware jobs and shared-to-tenant event replay; tested with SQLite and `activerecord-tenanted` |
18
+ | Send from an ordinary Ruby program | `Cloudflare::Email::Client` | Structured messages or complete raw MIME; no Rails/database required |
19
+ | Send existing Rails mailers | ActionMailer delivery method | Your mailer templates, attachments, multipart bodies, cc/bcc and reply headers sent through Cloudflare |
20
+ | Receive email in Rails | Email Worker + ActionMailbox | Unchanged raw MIME, attachments, authenticated SMTP envelope metadata and duplicate handling |
21
+ | Develop against real incoming email locally | `cloudflare:email:dev` | A temporary tunnel restricted to your email ingress |
22
+ | Save an email before sending it | Optional ActiveRecord outbox | Immutable message snapshot, saved recipients, a send claim, and per-recipient results |
23
+ | Avoid accidentally resending after a crash or timeout | `SendJob` + outbox | Jobs use the saved operation identity; uncertain attempts stay blocked for review |
24
+ | Track delivery, bounce or complaint updates | `EventConsumer` + Cloudflare Queue | Validated lifecycle events, account/domain checks and acknowledgement after successful handling |
25
+ | Avoid building your own event ledger | Optional tracking tables | Durable receipts, duplicate/conflict detection, unmatched-event storage and replay |
26
+ | Connect events to outbox recipients | `DeliveryEvents` | Account/message/recipient matching, event ordering and callbacks for your app's records |
27
+ | Investigate an uncertain send | Recovery tasks + `Outbox.reconcile` | Operator-supplied evidence and an audit trail; no automatic resend based on elapsed time |
28
+ | Match replies to earlier messages | Provider message IDs + `MessageId.normalize` | A consistent lookup key for your app's conversations |
29
+ | Observe email processing | ActiveSupport notifications + `doctor` | Send/ingress/event/outbox instrumentation and configuration diagnostics |
30
+ | Deploy receiving infrastructure | Ruby deployer and routing tasks | Environment-specific Workers, ingress secrets, address routes and DNS preflight checks |
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
+
60
+ ## SQLite works
61
+
62
+ The optional receipt and outbox tables work with **SQLite**. Keep your existing
63
+ Rails SQLite database; PostgreSQL is optional, and both adapters have concurrency
64
+ coverage. The gem uses ActiveRecord and does not install a separate database
65
+ service. You still choose a durable Rails job backend and run its workers.
66
+
67
+ Database locking conflicts can require a job retry. A retry uses the existing
68
+ outbox identity; it does not clear an uncertain send claim. Keep application
69
+ writes and gem callbacks on the same database connection when they must commit
70
+ together. See [outbox setup](outbox.md).
71
+
72
+ ## Understand the different kinds of success
73
+
74
+ | Result | What it tells you |
75
+ | --- | --- |
76
+ | Successful API request | Cloudflare returned a successful response; inspect recipient outcomes too |
77
+ | `response.accepted?` | There is evidence of acceptance for at least part of the send |
78
+ | Outbox `accepted` | Every recipient has acceptance evidence |
79
+ | Outbox `partial` | Some recipients have acceptance evidence; the others need individual attention |
80
+ | Delivery event `delivered` | The recipient's server accepted the email; this is not a read receipt |
81
+ | Ingress HTTP 200 | Rails stored the message or recognized a duplicate; mailbox processing happens separately |
82
+
83
+ Sending, receiving and delivery tracking are separate Cloudflare setups. A
84
+ working sending domain does not automatically enable incoming routes or queue
85
+ subscriptions. The [getting-started guide](getting-started.md) walks through each.
86
+
87
+ ## Protections included
88
+
89
+ By default, incoming requests use v2 HMAC signatures covering the timestamp, SMTP sender,
90
+ SMTP recipient and raw message. Rails checks a five-minute timestamp window.
91
+ Identical MIME retried for the same recipient is deduplicated; delivery to a
92
+ different recipient is stored separately.
93
+
94
+ Rails and the Worker default to a **25 MiB raw incoming message limit**. Both
95
+ support a positive `MAX_EMAIL_BYTES` override. Remote endpoints require HTTPS,
96
+ the Worker refuses redirects and limits its Rails request to 15 seconds, and
97
+ the development tunnel only routes ingress POSTs. Client inspection and default
98
+ retry/mailbox logging omit sensitive details.
99
+
100
+ The outbox preserves the rendered message and acceptance evidence, blocks
101
+ ambiguous resend attempts, and records reconciliation decisions. Delivery-event
102
+ validation rejects malformed schema fields; original receipt identity and
103
+ payload are read-only through normal ActiveRecord updates. These safeguards do
104
+ not make database administrators untrusted or provide exactly-once delivery.
105
+
106
+ See [managed mailbox setup](mailboxes.md) for the application-facing API and
107
+ [activerecord-tenanted](activerecord-tenanted.md) for separate SQLite databases.
108
+
109
+ ## What belongs in your mailbox application
110
+
111
+ The gem supplies email infrastructure and optional managed mailbox records.
112
+ Your app supplies users, mailbox permissions, conversation records, custom folders, search, compose screens,
113
+ drafts and any AI review or approval workflow. It also decides unsubscribe and
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.
117
+
118
+ An authenticated SMTP envelope tells your app which address Cloudflare received
119
+ the message for. It does not prove the human sender's identity. Reply correlation
120
+ also does not authorize access to a conversation. Apply your app's permissions
121
+ before displaying mail or sending a reply.
122
+
123
+ You configure Cloudflare domains/DNS, queue subscriptions, durable jobs,
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)
127
+ for the full boundary and [security guidance](../SECURITY.md) for deployment.
128
+
129
+ ## Where to go next
130
+
131
+ - [Build your first integration](getting-started.md)
132
+ - [Use plain Ruby, attachments or raw MIME](../README.md#plain-ruby)
133
+ - [Configure client retries and errors](../README.md#retry-and-configuration)
134
+ - [Set up event polling and durable receipts](delivery-events.md)
135
+ - [Recover saved outbound operations](outbox.md)
136
+ - [Correlate replies](thread-correlation.md)
137
+ - [Use Cloudflare SMTP instead of the HTTP delivery method](../README.md#smtp-alternative)
138
+ - [Troubleshoot your setup](troubleshooting.md)
@@ -0,0 +1,308 @@
1
+ # Get email working in your Rails app
2
+
3
+ This guide takes you from your first outgoing email to receiving replies and
4
+ tracking deliveries. Start with sending and add the other pieces as needed.
5
+ For a mailbox app, follow all four parts. **SQLite is supported throughout.**
6
+
7
+ You will need an existing Rails app, a Cloudflare account, and a domain you can
8
+ configure in Cloudflare. Use Ruby 3.2+ and a patched supported Rails release:
9
+ tested floors are 7.2.3.2, 8.0.5.1 and 8.1.3.1. Rails 8 requires Ruby 3.3+.
10
+ Ruby 4 is tested with Rails 8.1. Plain Ruby users can use the
11
+ [standalone client](../README.md#plain-ruby) instead.
12
+
13
+ Example addresses below use `mail.example.com` for sending and
14
+ `in.example.com` for receiving. Replace them with subdomains you own. They do
15
+ not need to be the same domain as your Rails application's web address.
16
+
17
+ ## 1. Send your first email
18
+
19
+ ### Install the current code
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:
23
+
24
+ ```ruby
25
+ gem "cloudflare-email", "~> 0.3.0"
26
+ ```
27
+
28
+ ```sh
29
+ bundle install
30
+ bin/rails generate cloudflare:email:install --no-inbound
31
+ ```
32
+
33
+ The generator creates an initializer that chooses `:cloudflare` as your
34
+ ActionMailer delivery method. The `--no-inbound` option keeps this first step
35
+ focused on sending; you can add receiving below.
36
+
37
+ ### Connect your sending domain
38
+
39
+ In Cloudflare, open **Compute → Email Service → Email Sending** and onboard
40
+ your sending domain. Complete Cloudflare's DNS instructions and wait for domain
41
+ verification. A dedicated subdomain helps keep an existing mail provider's apex
42
+ configuration separate. Do not replace existing apex mail records casually.
43
+
44
+ Create an account-scoped token with permission to send email. Add these variables
45
+ to the environment where Rails runs, or use the equivalent Rails credentials:
46
+
47
+ ```sh
48
+ export CLOUDFLARE_ACCOUNT_ID=your-account-id
49
+ export CLOUDFLARE_API_TOKEN=your-email-sending-token
50
+ ```
51
+
52
+ Rails credentials use `cloudflare.account_id` and `cloudflare.api_token`.
53
+ Nonempty Rails credentials take precedence over environment variables. Keep
54
+ real tokens out of committed files. Restart Rails after changing configuration.
55
+
56
+ ### Check the setup and send
57
+
58
+ ```sh
59
+ bin/rails cloudflare:email:doctor
60
+ FROM=hello@mail.example.com TO=you@example.net bin/rails cloudflare:email:send_test
61
+ ```
62
+
63
+ Use a destination inbox you control. `doctor` checks configuration and available
64
+ read access; the second command actually sends mail. Confirm that it arrives,
65
+ including checking spam. A diagnostics success alone does not prove delivery.
66
+
67
+ ### Use your regular mailers
68
+
69
+ ```ruby
70
+ # app/mailers/hello_mailer.rb
71
+ class HelloMailer < ApplicationMailer
72
+ def hello(address)
73
+ mail(from: "hello@mail.example.com", to: address, subject: "Hello from Rails") do |format|
74
+ format.text { render plain: "Your email integration is working." }
75
+ end
76
+ end
77
+ end
78
+ ```
79
+
80
+ From `bin/rails console`:
81
+
82
+ ```ruby
83
+ HelloMailer.hello("you@example.net").deliver_now
84
+ ```
85
+
86
+ Your existing HTML templates, multipart messages, attachments and cc/bcc work
87
+ through ActionMailer. Use `deliver_later` with your app's job backend for ordinary
88
+ background delivery. For saved send history and protection against ambiguous
89
+ resends, use the outbox in part 3. Do not send the same message through both paths.
90
+
91
+ ## 2. Receive email and replies
92
+
93
+ Incoming email follows this path:
94
+
95
+ ```text
96
+ Sender → Cloudflare Email Routing → Email Worker → Rails ActionMailbox → your app
97
+ ```
98
+
99
+ The Worker is a small program that forwards the email to Rails and signs it so
100
+ Rails can verify the forwarding request. The installer supplies its code.
101
+
102
+ ### Install receiving support
103
+
104
+ ```sh
105
+ bin/rails generate cloudflare:email:install
106
+ bin/rails db:migrate
107
+ ```
108
+
109
+ Follow the interactive prompts to install ActionMailbox and the default mailbox.
110
+ If you already have custom mailbox routing or initializer changes, inspect the
111
+ generator's overwrite prompts before accepting. The generated default mailbox
112
+ only logs receipt; you will add your product's processing logic.
113
+
114
+ Save the generated shared secret as `cloudflare.ingress_secret` in Rails
115
+ credentials or `CLOUDFLARE_INGRESS_SECRET`. The deploy task copies that secret to
116
+ the Worker. Use a separate deployment token as `cloudflare.management_token` or
117
+ `CLOUDFLARE_MANAGEMENT_TOKEN`; [the permission table](../README.md#observability-and-permissions)
118
+ lists the scopes needed for Worker deployment and routing.
119
+
120
+ ### Connect the receiving address
121
+
122
+ In Cloudflare **Email Routing → your apex domain → Settings → Subdomains**, add
123
+ your receiving subdomain, such as `in.example.com`, and finish its DNS setup.
124
+ This is separate from verifying the sending domain.
125
+
126
+ Deploy Rails with ActionMailbox storage and workers configured. For guided
127
+ Worker setup, use the [Deploy to Cloudflare template](../templates/deploy-to-cloudflare/README.md).
128
+ It provisions R2 and Queues and prompts for your Rails URL and shared secret.
129
+ Then select that Worker in your Email Routing rule as described in its guide.
130
+
131
+ For the CLI setup instead, run these commands in an environment with production
132
+ configuration and deployment credentials:
133
+
134
+ First create the private R2 bucket and Queue, then deploy the generated Wrangler
135
+ configuration using the [durable setup guide](../templates/worker/docs/durable-inbound.md).
136
+ The Ruby task below updates that provisioned Worker while preserving its bindings.
137
+ It refuses a missing bucket/queue binding or recovery schedule before changing code.
138
+
139
+ ```sh
140
+ RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
141
+ RAILS_ENV=production bin/rails cloudflare:email:provision_route ADDRESS=support@in.example.com
142
+ ```
143
+
144
+ The first command deploys the Worker; the second routes the address to it.
145
+ Send a real test email to `support@in.example.com`, then check the ActionMailbox
146
+ record and processing job. A web request accepted by Rails means storage worked;
147
+ the mailbox job can still need attention.
148
+
149
+ ### Read the message in your mailbox
150
+
151
+ Inside the generated `MainMailbox#process`, these values are available:
152
+
153
+ ```ruby
154
+ envelope = Cloudflare::Email::Envelope.for(inbound_email)
155
+ receiving_address = envelope&.fetch("to")
156
+ subject = mail.subject
157
+ text = mail.text_part&.body&.decoded || (mail.body.decoded unless mail.multipart?)
158
+ attachments = mail.attachments
159
+ raw_message = inbound_email.raw_email.download
160
+ ```
161
+
162
+ Save or process those values using your application's models. For mailbox or
163
+ tenant selection, use `receiving_address`, which comes from the authenticated
164
+ SMTP envelope. MIME `To`/`Cc` headers may differ, especially for Bcc. The envelope
165
+ can be absent for records from another ingress; handle that case explicitly.
166
+ Treat message contents and attachments as untrusted input when displaying them.
167
+
168
+ To direct replies to this address, add `reply_to: "support@in.example.com"` to
169
+ your mailer's `mail(...)` call. For conversation matching, save the returned
170
+ provider message ID and compare reply headers within your application's mailbox
171
+ scope. The outbox saves this ID for you. See [thread correlation](thread-correlation.md).
172
+
173
+ ### Receive into a local Rails server
174
+
175
+ Install `cloudflared`, configure development credentials and a separate development
176
+ receiving route, then start Rails. In another terminal:
177
+
178
+ ```sh
179
+ INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
180
+ bin/rails cloudflare:email:dev
181
+ ```
182
+
183
+ The first command creates the development Worker with the explicit direct fallback; the second points it at a
184
+ temporary tunnel to your local Rails server on port 3000. Keep the tunnel running
185
+ while testing and stop it with Ctrl-C. Set `PORT=...` if Rails uses another port.
186
+ The task checks that Rails has the ingress-only guard; restart Rails if asked.
187
+ Use development test data. The tunnel URL is temporary and does not provide an
188
+ offline mailbox when your local server is stopped.
189
+
190
+ ## 3. Add the durable outbox with SQLite
191
+
192
+ An outbox saves the exact message before sending it. Background jobs reference
193
+ that saved operation instead of re-rendering and resending mail on every retry.
194
+ This is useful for compose, replies, invoices and approved drafts.
195
+
196
+ If your Rails app already uses SQLite, keep its database configuration. Run:
197
+
198
+ ```sh
199
+ bin/rails generate cloudflare:email:tracking
200
+ bin/rails generate cloudflare:email:outbox
201
+ bin/rails db:migrate
202
+ ```
203
+
204
+ These add the receipt and delivery tables plus opt-in initializers. Skip a
205
+ generator whose migration you already installed. Configure a durable ActiveJob
206
+ backend and run a worker that processes the `mailers` queue; the gem does not
207
+ install the backend for you.
208
+
209
+ Try this from Rails console, outside an existing database transaction:
210
+
211
+ ```ruby
212
+ account_id = Cloudflare::Email::Credentials.account_id
213
+ operation = Cloudflare::Email::ActiveRecord::Outbox.prepare_mail(
214
+ account_id: account_id,
215
+ operation_key: "getting-started:hello:1",
216
+ mail: HelloMailer.hello("you@example.net").message,
217
+ )
218
+ Cloudflare::Email::SendJob.perform_later(account_id, operation.operation_key)
219
+ ```
220
+
221
+ The operation key identifies **one intended send** in your account. Save it with
222
+ your product record. For a genuinely new message use a new key; for a job retry
223
+ reuse the saved identity. Do not re-render a mailer and call `prepare_mail` as
224
+ your retry mechanism: generated MIME headers can change, causing a snapshot conflict.
225
+
226
+ In an application transaction, prepare the operation alongside your business
227
+ record, then enqueue only after the outer transaction commits. The detailed
228
+ [outbox guide](outbox.md#prepare-once-dispatch-after-commit) shows that pattern.
229
+
230
+ After the worker runs, inspect the result:
231
+
232
+ ```ruby
233
+ operation.reload.state
234
+ operation.provider_message_id
235
+ operation.outbound_recipients.pluck(:recipient, :acceptance_state, :state)
236
+ ```
237
+
238
+ `accepted` means all recipients have provider acceptance evidence; `partial`
239
+ means only some do. Neither promises that a human received or read the message.
240
+ `unknown` or a stuck `sending` operation needs investigation, not a new send key
241
+ as a shortcut. See [states and recovery](outbox.md#states-and-retries).
242
+
243
+ ## 4. Track delivery, bounces and complaints
244
+
245
+ Cloudflare sends later delivery updates through a Queue. You configure that
246
+ Queue and its Email Sending subscription once; your app polls it regularly.
247
+ Follow [the provisioning steps](delivery-events.md#provision-once), including an
248
+ HTTP pull consumer, retries and a dead-letter queue for repeatedly failing events.
249
+
250
+ Add a separate queue token and queue ID to your Rails environment:
251
+
252
+ ```sh
253
+ export CLOUDFLARE_QUEUES_TOKEN=your-queues-read-write-token
254
+ export CLOUDFLARE_EVENT_QUEUE_ID=your-queue-id
255
+ ```
256
+
257
+ With the outbox installed, configure this initializer:
258
+
259
+ ```ruby
260
+ # config/initializers/cloudflare_delivery_events.rb
261
+ require "cloudflare/email/active_record"
262
+
263
+ Rails.application.configure do
264
+ config.x.cloudflare_email.event_domains = ["mail.example.com"]
265
+ config.x.cloudflare_email.event_handler = ->(event) {
266
+ Cloudflare::Email::ActiveRecord::DeliveryEvents.record(event)
267
+ }
268
+ end
269
+ ```
270
+
271
+ Restart Rails and poll a batch:
272
+
273
+ ```sh
274
+ bin/rails cloudflare:email:consume_events
275
+ ```
276
+
277
+ This command processes **one batch and exits**. Schedule it repeatedly using
278
+ your application's scheduler. Also schedule receipt replay for recovery:
279
+
280
+ ```sh
281
+ bin/rails cloudflare:email:replay_events
282
+ ```
283
+
284
+ The gem saves receipts before acknowledgement, handles duplicates, and matches
285
+ updates to the account, provider message ID and recipient. If an event arrives
286
+ before the send result is saved, it stays available for replay. Recipient state
287
+ then reflects delivered, deferred, bounced, failed, rejected or complained events.
288
+ Your app still decides what those outcomes mean for its UI and future sending.
289
+
290
+ For custom product updates, use the [transactional callbacks](outbox.md#recover-dispatch-and-application-projections).
291
+ If you only want events and have your own delivery models, use the lower-level
292
+ [durable receipt adapter](delivery-events.md#durable-rails-receipts).
293
+
294
+ ## Before relying on it
295
+
296
+ Send to an external inbox you control, reply back, verify stored attachments,
297
+ and confirm a real queue event updates the intended recipient. Keep a durable
298
+ job worker running and schedule recovery of `prepared` operations that might
299
+ have committed before enqueueing. Monitor stuck operations and failed mailbox jobs.
300
+
301
+ Protect stored MIME and attachments, choose retention and backup policies, and
302
+ set proxy/server request limits. The default incoming raw message limit is
303
+ 25 MiB; set the same positive `MAX_EMAIL_BYTES` on Rails and the Worker if you
304
+ override it. Cloudflare has separate [outgoing size and recipient limits](https://developers.cloudflare.com/email-service/platform/limits/).
305
+
306
+ Use [troubleshooting](troubleshooting.md) when something does not arrive,
307
+ [the feature overview](features.md) to explore more, and
308
+ [the upgrade guide](upgrading-0.3.md) for an existing 0.1 installation.