cloudflare-email 0.2.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +76 -0
  3. data/README.md +34 -9
  4. data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
  5. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  6. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  7. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  8. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  9. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  10. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  11. data/docs/activerecord-tenanted.md +46 -0
  12. data/docs/architecture.md +13 -3
  13. data/docs/custom-ingress.md +207 -0
  14. data/docs/delivery-events.md +9 -0
  15. data/docs/features.md +36 -5
  16. data/docs/getting-started.md +18 -8
  17. data/docs/mailboxes.md +67 -3
  18. data/docs/management-engine.md +190 -0
  19. data/docs/routing-deliveries.md +94 -0
  20. data/docs/routing-diagnostics.md +68 -0
  21. data/docs/troubleshooting.md +4 -3
  22. data/docs/upgrading-0.2.md +5 -0
  23. data/docs/upgrading-0.3.md +55 -0
  24. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  25. data/docs/verification/2026-09-12-destination-context.md +46 -0
  26. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  27. data/lib/cloudflare/email/active_record/base.rb +13 -1
  28. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  29. data/lib/cloudflare/email/check_route_task.rb +21 -0
  30. data/lib/cloudflare/email/client.rb +45 -10
  31. data/lib/cloudflare/email/delivery_method.rb +2 -0
  32. data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
  33. data/lib/cloudflare/email/engine.rb +5 -0
  34. data/lib/cloudflare/email/envelope.rb +2 -2
  35. data/lib/cloudflare/email/event_consumer.rb +12 -3
  36. data/lib/cloudflare/email/ingress.rb +115 -0
  37. data/lib/cloudflare/email/mailboxes/models.rb +19 -2
  38. data/lib/cloudflare/email/mailboxes/service.rb +99 -15
  39. data/lib/cloudflare/email/management/adapter.rb +31 -0
  40. data/lib/cloudflare/email/management/configuration.rb +17 -0
  41. data/lib/cloudflare/email/management/engine.rb +15 -0
  42. data/lib/cloudflare/email/management/management.css +68 -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/verification.rb +17 -9
  49. data/lib/cloudflare/email/version.rb +1 -1
  50. data/lib/cloudflare/email/worker_deployer.rb +36 -5
  51. data/lib/generators/cloudflare/email/install_generator.rb +6 -5
  52. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  53. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  54. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
  55. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  56. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  57. data/lib/tasks/cloudflare_email.rake +2 -1
  58. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  59. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  60. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  61. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  62. data/templates/deploy-to-cloudflare/README.md +84 -0
  63. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  64. data/templates/deploy-to-cloudflare/package.json +36 -0
  65. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  66. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  67. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  68. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  69. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  70. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  71. data/templates/worker/README.md +180 -15
  72. data/templates/worker/docs/durable-inbound.md +149 -0
  73. data/templates/worker/package-lock.json +2 -2
  74. data/templates/worker/package.json +1 -1
  75. data/templates/worker/src/index.js +331 -38
  76. data/templates/worker/test/durable.test.ts +193 -0
  77. data/templates/worker/test/index.test.ts +78 -20
  78. data/templates/worker/test/relay.test.ts +226 -0
  79. data/templates/worker/wrangler.toml +50 -0
  80. metadata +49 -3
@@ -0,0 +1,86 @@
1
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span> <span><%= @mailbox.name %></span></nav>
2
+ <div class="page-heading">
3
+ <div>
4
+ <p class="eyebrow">Mailbox</p>
5
+ <h1><%= @mailbox.name %></h1>
6
+ <p class="muted"><%= @mailbox.state == "suspended" ? "Receiving is paused for every address in this mailbox." : "Addresses receive mail when both their address and domain are active." %></p>
7
+ </div>
8
+ <div class="actions">
9
+ <span class="badge"><%= @mailbox.state.capitalize %></span>
10
+ <% if @mailbox.state == "active" && allowed?(:suspend, @mailbox) %>
11
+ <%= button_to "Suspend mailbox", suspend_mailbox_path(@mailbox.id), method: :post, class: "button button-secondary" %>
12
+ <% elsif @mailbox.state == "suspended" && allowed?(:resume, @mailbox) %>
13
+ <%= button_to "Resume mailbox", resume_mailbox_path(@mailbox.id), method: :post, class: "button" %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+
18
+ <div class="workspace-grid">
19
+ <section class="panel" aria-labelledby="messages-heading">
20
+ <div class="panel-heading"><h2 id="messages-heading">Received messages</h2><p class="muted small">Includes archived messages</p></div>
21
+ <% if @messages.empty? %>
22
+ <div class="empty-state"><h3>No messages to show</h3><p>Messages appear here after delivery to an active address.</p></div>
23
+ <% else %>
24
+ <ul class="record-list">
25
+ <% @messages.each do |entry| %>
26
+ <li class="record-row">
27
+ <div class="record-content">
28
+ <% if allowed?(:show_message, @mailbox) %>
29
+ <%= link_to "Message to #{entry.recipient}", message_mailbox_path(@mailbox.id, message_id: entry.id), class: "record-title" %>
30
+ <% else %>
31
+ <span class="record-title">Message to <%= entry.recipient %></span>
32
+ <% end %>
33
+ <p class="muted small"><time datetime="<%= entry.created_at.iso8601 %>"><%= entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></p>
34
+ </div>
35
+ <div class="status-list">
36
+ <span class="badge"><%= entry.read_at ? "Read" : "Unread" %></span>
37
+ <% if entry.archived_at %><span class="badge">Archived</span><% end %>
38
+ </div>
39
+ </li>
40
+ <% end %>
41
+ </ul>
42
+ <% end %>
43
+ <% if @next_cursor.present? %>
44
+ <nav class="pagination" aria-label="Message pages"><%= link_to "Next messages →", mailbox_path(@mailbox.id, after: @next_cursor), class: "button button-secondary" %></nav>
45
+ <% end %>
46
+ </section>
47
+
48
+ <div class="stack">
49
+ <section class="panel" aria-labelledby="addresses-heading">
50
+ <div class="panel-heading"><h2 id="addresses-heading">Addresses</h2></div>
51
+ <ul class="record-list">
52
+ <% @addresses.each do |address| %>
53
+ <% receiving = @mailbox.state == "active" && address.state == "active" && address.receiving_domain&.state == "active" %>
54
+ <li class="record-row address-row">
55
+ <span class="record-title"><%= address.address %></span>
56
+ <% if address.has_attribute?(:catch_all) && address[:catch_all] %>
57
+ <span class="badge">Domain catch-all configured</span>
58
+ <% end %>
59
+ <span class="badge"><%= receiving ? "Accepting mail" : address.state == "pending" ? "Pending setup" : "Receiving paused" %></span>
60
+ </li>
61
+ <% end %>
62
+ </ul>
63
+ <div class="panel-body"><p class="field-help">Pending addresses need routing confirmation from your administrator. Resuming a mailbox preserves each address’s existing setup state.</p></div>
64
+ </section>
65
+
66
+ <% if allowed?(:add_address, @mailbox) %>
67
+ <section class="panel" aria-labelledby="alias-heading">
68
+ <div class="panel-heading"><h2 id="alias-heading">Add an alias</h2></div>
69
+ <div class="panel-body">
70
+ <% if @domains.empty? %>
71
+ <p class="muted">No active receiving domains are available. Contact your administrator.</p>
72
+ <% else %>
73
+ <%= form_with url: aliases_mailbox_path(@mailbox.id), local: true, class: "stack" do |form| %>
74
+ <div class="field">
75
+ <%= form.label :address, "Alias email address" %>
76
+ <%= form.email_field :address, required: true, maxlength: 254, aria: { describedby: "alias-domains" } %>
77
+ <p id="alias-domains" class="field-help">Available domains: <%= @domains.join(", ") %>. Messages to this alias will appear in this mailbox.</p>
78
+ </div>
79
+ <%= form.submit "Add alias", class: "button" %>
80
+ <% end %>
81
+ <% end %>
82
+ </div>
83
+ </section>
84
+ <% end %>
85
+ </div>
86
+ </div>
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Mailbox management</title>
7
+ <%= csrf_meta_tags %>
8
+ <%= stylesheet_link_tag style_path %>
9
+ </head>
10
+ <body>
11
+ <a class="skip-link" href="#main">Skip to content</a>
12
+ <header class="site-header">
13
+ <div class="shell header-content">
14
+ <%= link_to "Mailbox management", root_path, class: "brand" %>
15
+ <span class="header-caption">Cloudflare Email</span>
16
+ <% if host_back_path %><%= link_to "Back to app", host_back_path %><% end %>
17
+ </div>
18
+ </header>
19
+ <main id="main" class="shell" tabindex="-1">
20
+ <% if flash[:notice].present? %>
21
+ <div class="notice" role="status"><%= flash[:notice] %></div>
22
+ <% end %>
23
+ <% if flash[:alert].present? %>
24
+ <div class="notice notice-error" role="alert"><%= flash[:alert] %></div>
25
+ <% end %>
26
+ <%= yield %>
27
+ </main>
28
+ </body>
29
+ </html>
@@ -123,6 +123,52 @@ end
123
123
 
124
124
  See the [mailbox guide](mailboxes.md) for domain activation, address provisioning, sending, and event replay. Your application still authorizes which organization, mailbox, and message each user can access.
125
125
 
126
+ ## Account emails without an organization
127
+
128
+ Password resets and portal invitations can belong to the application rather
129
+ than an organization. If you enabled separate tenant databases, give those
130
+ outbox records an explicitly provisioned, host-reserved database too. Keep the
131
+ business record's organization empty; a storage key is not an organization or
132
+ an authorization grant.
133
+
134
+ For example, after configuring the adapter above, provision and migrate a
135
+ reserved database through your deployment workflow:
136
+
137
+ ```ruby
138
+ # Run once through trusted provisioning, not during sending or event replay.
139
+ TenantRecord.create_tenant("_system_outbound")
140
+ ```
141
+
142
+ Your own service can select storage consistently for sending and feedback:
143
+
144
+ ```ruby
145
+ module EmailStorage
146
+ SYSTEM_KEY = "_system_outbound".freeze
147
+
148
+ def self.with_outbound_storage(organization_key, &block)
149
+ # The caller must supply an already-authorized organization key.
150
+ key = organization_key.nil? ? SYSTEM_KEY : organization_key
151
+ Cloudflare::Email::Tenancy.with(key, &block)
152
+ end
153
+ end
154
+
155
+ EmailStorage.with_outbound_storage(nil) do
156
+ # Prepare/read/reconcile the system email's saved outbox operation here.
157
+ end
158
+ ```
159
+
160
+ Reserve the key against customer selection, include this database in migrations,
161
+ backups and restoration drills, and use the same selection for receipt replay.
162
+ If your shared business records use another database, retain their own durable
163
+ receipt/projection boundary: a tenant commit and a shared commit are not atomic.
164
+ Replay a committed gem operation rather than sending it again.
165
+
166
+ Never modify the tenancy adapter to treat every missing context as the system
167
+ database. Missing context must continue to fail closed. Your organization's job
168
+ fan-out should enumerate real organizations, not every database file. Applications
169
+ without configured multi-tenancy can keep ordinary system and organization mail
170
+ in their existing single database; this recipe does not enable tenancy by default.
171
+
126
172
  ## What is verified
127
173
 
128
174
  The optional CI job boots a real Rails application with `activerecord-tenanted`, runs the gem's tenant migrations in two SQLite databases, and checks identical numeric IDs remain isolated. It also verifies context restoration, refusal to create unknown tenant databases, and GlobalID's missing/wrong-tenant rejection. Separate integration tests exercise mailbox ingress, sending, jobs, and delivery-event replay.
data/docs/architecture.md CHANGED
@@ -14,7 +14,7 @@ durable infrastructure without copying the reference inbox's models and services
14
14
  | Shared event intake, tenant correlation and tenant-aware jobs | Durable queue workers, scheduled recovery and schema rollout to every tenant |
15
15
  | Structured/raw sending and ActionMailer transport | Compose UI, recipients and send authorization |
16
16
  | `Response#accepted?`, plus individual recipient outcomes | Handle partial acceptance without resending accepted recipients |
17
- | Authenticated v2 ingress and recipient-scoped deduplication | Map trusted recipient to an authorized mailbox |
17
+ | Authenticated ingress (default v2; opt-in v3 [metadata](custom-ingress.md)) and context-scoped deduplication | Map trusted recipient to an authorized mailbox; apply sender and document policy |
18
18
  | `MessageId.normalize` and returned provider IDs | Store conversation membership and scope reply lookups |
19
19
  | Queue decoding, validation and ACK | Queue/subscription setup and recurring execution |
20
20
  | Optional ActiveRecord event receipts, deduplication and indexed replay | Configure queue polling and receipt retention |
@@ -75,7 +75,15 @@ does not automatically provision domains or deploy infrastructure.
75
75
 
76
76
  Current limitations are deliberate and visible: no provider exactly-once API,
77
77
  no automatic resend of uncertain or rejected operations, no multi-provider-ID
78
- operation (conflicting IDs require review), and no durable inbound Worker buffer.
78
+ operation (conflicting IDs require review). Direct inbound forwarding can reject
79
+ mail during a Rails outage. The default [durable Worker path](../templates/worker/README.md#durable-inbound-delivery)
80
+ commits raw mail and stable envelope context to R2 before returning successfully.
81
+ Queue handoffs and a scheduled recovery pass retry pending mail; a Rails 2xx
82
+ allows removal from the pending store. Queue expiration does not remove R2 mail.
83
+ This boundary requires durable Rails persistence before 2xx, idempotent replay,
84
+ private R2 storage without expiration of pending objects, and operational
85
+ monitoring. It cannot guarantee acceptance when Cloudflare itself cannot store
86
+ the incoming message.
79
87
  A prepared operation can be dispatched using its existing identity after a job
80
88
  enqueue failure; sending/unknown operations require evidence-based reconciliation.
81
89
  Notifications indicate method execution and may run inside an outer transaction;
@@ -92,4 +100,6 @@ silently impose a marketing subscription model on transactional mail.
92
100
  The optional [mailbox module](mailboxes.md) now packages mailbox persistence and
93
101
  address management over the existing transport/receipt APIs. It includes a
94
102
  [tenant adapter](activerecord-tenanted.md), without adding a tenant library to
95
- the plain Ruby client. A future inbox UI or starter should consume these APIs.
103
+ the plain Ruby client. The optional [management engine](management-engine.md)
104
+ consumes these same APIs with host-supplied authentication and authorization.
105
+ Full inbox products can continue using them through their own frontends.
@@ -0,0 +1,207 @@
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
+ Cloudflare::Email::Mailboxes.receive(
46
+ recipient: verified.envelope.fetch("to")
47
+ ) do |destination|
48
+ # This block runs in the recipient's tenant. Apply your sender/review
49
+ # policy here, before persistence schedules Action Mailbox routing.
50
+ # Your policy may raise a host error or return nil instead of persisting.
51
+ # destination.owner_ref identifies your site/team; validate it using
52
+ # your application's policy in this tenant context.
53
+ verified.persist_action_mailbox!
54
+ end
55
+
56
+ head :ok
57
+ rescue Cloudflare::Email::Mailboxes::Unavailable
58
+ head :unprocessable_entity
59
+ end
60
+ end
61
+ ```
62
+
63
+ 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.
64
+
65
+ Do not use the MIME `To` header or an unauthenticated URL subdomain to select storage. `verified.envelope.fetch("to")` is the signed SMTP recipient. `Mailboxes.receive` checks that its domain, mailbox, and accepted address are active before entering the persistence block.
66
+
67
+ `persist_action_mailbox!` stores the verified raw bytes and metadata in Action Mailbox. The receiving block adds mailbox membership in the same tenant transaction. Rails schedules normal routing after the transaction commits. Apply policies that must stop processing before calling persistence; a check performed after receiving returns can be too late.
68
+
69
+ For an application that owns a different raw-email store, use `verified.body`, `verified.envelope`, `verified.provider_metadata`, `verified.message_checksum`, and `verified.storage_metadata` with your own persistence/transaction system. `Mailboxes.receive` specifically expects an Action Mailbox inbound email record (or nil) from its block; do not pass an unrelated processing record. The gem does not choose your archive retention, held-message model, or document queue.
70
+
71
+ ## Persist the email and link your business record
72
+
73
+ Email persistence remains part of the gem's supported path. Action Mailbox stores
74
+ the raw MIME using Active Storage, the verifier saves authenticated metadata, and
75
+ `Mailboxes.receive` adds the inbox membership. Your processing/review record can
76
+ refer to that email rather than storing a second copy of its raw content:
77
+
78
+ ```ruby
79
+ Cloudflare::Email::Mailboxes.receive(
80
+ recipient: verified.envelope.fetch("to")
81
+ ) do |destination|
82
+ # Validate owner_ref and sender policy here, before any routing is scheduled.
83
+ inbound = verified.persist_action_mailbox!
84
+ if inbound
85
+ # Example host model, on the SAME tenant connection as the gem records.
86
+ EmailIntake.create!(
87
+ inbound_email_id: inbound.id,
88
+ mailbox_id: destination.mailbox_id,
89
+ owner_ref: destination.owner_ref
90
+ )
91
+ end
92
+ inbound # Return the ActionMailbox record; duplicate deliveries return nil.
93
+ end
94
+ ```
95
+
96
+ The database writes commit together. A host failure rolls back the new email,
97
+ membership and host link and prevents the routing job from being enqueued. This
98
+ does not make an R2 archive, another database, or external API calls transactional.
99
+ Host exceptions propagate; only unavailable registry destinations are translated
100
+ to `Mailboxes::Unavailable`. Existing blocks without a destination argument still
101
+ work.
102
+
103
+ Persisting through ActionMailbox schedules its normal routing after commit. Put
104
+ your processing in that route or a deliberately coordinated job pipeline. If an
105
+ email must be held, implement a routing policy that cannot process it before
106
+ approval; storing it is not itself a review gate. A routing job failure also needs
107
+ normal job retry/recovery. Receipt deduplication does not make every downstream
108
+ business side effect idempotent.
109
+
110
+ Managed mailbox membership protects raw email from ActionMailbox's normal
111
+ automatic incineration. Archiving a mailbox message keeps its content; deliberate
112
+ purging can remove it once no other membership references the email. Raw email
113
+ saved without membership follows the normal ActionMailbox lifecycle. Keep host
114
+ references, backup and R2 retention consistent with your chosen deletion policy.
115
+ Multi-tenancy remains opt-in.
116
+
117
+ ## Resolve an address with your own persistence
118
+
119
+ If your application already owns its raw-email store, use the standalone API:
120
+
121
+ ```ruby
122
+ Cloudflare::Email::Mailboxes.with_recipient(
123
+ recipient: verified.envelope.fetch("to")
124
+ ) do |destination|
125
+ # Example host-owned persistence service; it must commit durably before ACK.
126
+ ExistingEmailStore.save!(
127
+ tenant_key: destination.tenant_key,
128
+ mailbox_id: destination.mailbox_id,
129
+ recipient: destination.recipient,
130
+ raw: verified.body,
131
+ metadata: verified.storage_metadata
132
+ )
133
+ end
134
+ ```
135
+
136
+ This checks the active domain, address and mailbox, enters the configured tenant,
137
+ and yields an immutable `Destination` with `tenant_key`, `mailbox_id`, `address_id`,
138
+ `receiving_domain_id`, `recipient` and `owner_ref`. It returns the block's result,
139
+ creates no email or membership, starts no storage transaction, and requires no
140
+ ActionMailbox. Configure the optional mailbox registry and tenant adapter as usual.
141
+
142
+ Both APIs must be called after complete request verification. Destination values
143
+ are routing snapshots, not authorization tokens: an owner reference does not
144
+ prove a site still exists or that a sender may submit to it. Use records inside
145
+ their tenant context and recheck lifecycle/permissions when later work requires
146
+ it. Both APIs restore the previous tenant context when the block returns or raises.
147
+
148
+ ## Carry Worker metadata with authenticated provenance
149
+
150
+ The default Worker protocol remains v2: it authenticates the raw message and SMTP envelope. Optional v3 also authenticates a bounded metadata object:
151
+
152
+ ```json
153
+ {
154
+ "source": "cloudflare",
155
+ "data": {
156
+ "archive_key": "inbound/example.eml"
157
+ }
158
+ }
159
+ ```
160
+
161
+ 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.
162
+
163
+ 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.
164
+
165
+ 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:
166
+
167
+ ```js
168
+ import { signedEmailHeaders } from "./cloudflare-ingress.js";
169
+
170
+ const headers = await signedEmailHeaders({
171
+ secret: env.INGRESS_SECRET,
172
+ raw, // Uint8Array from your bounded reader
173
+ from: message.from,
174
+ to: message.to,
175
+ metadata: { source: "cloudflare", data: { archive_key: archiveKey } },
176
+ });
177
+ // Send exactly raw with these headers using your existing transport.
178
+ ```
179
+
180
+ 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.
181
+
182
+ In your Action Mailbox handler, read persisted metadata through the gem:
183
+
184
+ ```ruby
185
+ require "cloudflare/email/provider_metadata"
186
+
187
+ metadata = Cloudflare::Email::ProviderMetadata.for(inbound_email)
188
+ archive_key = metadata&.dig("data", "archive_key")
189
+ ```
190
+
191
+ 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.
192
+
193
+ **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.
194
+
195
+ 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.
196
+
197
+ ## Adopt incrementally
198
+
199
+ 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.
200
+ 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.
201
+ 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.
202
+ 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.
203
+ 5. Retire duplicate infrastructure only after comparing delivery and recovery behavior. Inbound adoption does not require switching your outbound provider.
204
+
205
+ 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.
206
+
207
+ 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.3.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,11 @@ 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.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
23
 
24
24
  ```ruby
25
- gem "cloudflare-email", "~> 0.2.0"
25
+ gem "cloudflare-email", "~> 0.3.0"
26
26
  ```
27
27
 
28
28
  ```sh
@@ -123,8 +123,18 @@ In Cloudflare **Email Routing → your apex domain → Settings → Subdomains**
123
123
  your receiving subdomain, such as `in.example.com`, and finish its DNS setup.
124
124
  This is separate from verifying the sending domain.
125
125
 
126
- Deploy Rails with ActionMailbox storage and workers configured. Then run these
127
- commands in an environment with production configuration and deployment credentials:
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.
128
138
 
129
139
  ```sh
130
140
  RAILS_ENV=production bin/rails cloudflare:email:deploy_worker URL=https://app.example.com/rails/action_mailbox/cloudflare/inbound_emails
@@ -166,11 +176,11 @@ Install `cloudflared`, configure development credentials and a separate developm
166
176
  receiving route, then start Rails. In another terminal:
167
177
 
168
178
  ```sh
169
- bin/rails cloudflare:email:deploy_worker
179
+ INBOUND_DELIVERY_MODE=direct bin/rails cloudflare:email:deploy_worker
170
180
  bin/rails cloudflare:email:dev
171
181
  ```
172
182
 
173
- The first command creates the development Worker; the second points it at a
183
+ The first command creates the development Worker with the explicit direct fallback; the second points it at a
174
184
  temporary tunnel to your local Rails server on port 3000. Keep the tunnel running
175
185
  while testing and stop it with Ctrl-C. Set `PORT=...` if Rails uses another port.
176
186
  The task checks that Rails has the ingress-only guard; restart Rails if asked.
@@ -295,4 +305,4 @@ override it. Cloudflare has separate [outgoing size and recipient limits](https:
295
305
 
296
306
  Use [troubleshooting](troubleshooting.md) when something does not arrive,
297
307
  [the feature overview](features.md) to explore more, and
298
- [the upgrade guide](upgrading-0.2.md) for an existing 0.1 installation.
308
+ [the upgrade guide](upgrading-0.3.md) for an existing 0.1 installation.