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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +76 -0
- data/README.md +34 -9
- data/app/controllers/cloudflare/email/ingress_controller.rb +16 -60
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
- data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
- data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
- data/docs/activerecord-tenanted.md +46 -0
- data/docs/architecture.md +13 -3
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +9 -0
- data/docs/features.md +36 -5
- data/docs/getting-started.md +18 -8
- data/docs/mailboxes.md +67 -3
- data/docs/management-engine.md +190 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/troubleshooting.md +4 -3
- data/docs/upgrading-0.2.md +5 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-12-destination-context.md +46 -0
- data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
- data/lib/cloudflare/email/active_record/base.rb +13 -1
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +45 -10
- data/lib/cloudflare/email/delivery_method.rb +2 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +3 -3
- data/lib/cloudflare/email/engine.rb +5 -0
- data/lib/cloudflare/email/envelope.rb +2 -2
- data/lib/cloudflare/email/event_consumer.rb +12 -3
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/models.rb +19 -2
- data/lib/cloudflare/email/mailboxes/service.rb +99 -15
- data/lib/cloudflare/email/management/adapter.rb +31 -0
- data/lib/cloudflare/email/management/configuration.rb +17 -0
- data/lib/cloudflare/email/management/engine.rb +15 -0
- data/lib/cloudflare/email/management/management.css +68 -0
- data/lib/cloudflare/email/management/routes.rb +14 -0
- data/lib/cloudflare/email/management.rb +7 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/verification.rb +17 -9
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +36 -5
- data/lib/generators/cloudflare/email/install_generator.rb +6 -5
- data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
- data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +4 -0
- data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
- data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
- data/lib/tasks/cloudflare_email.rake +2 -1
- data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
- data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
- data/templates/deploy-to-cloudflare/.gitignore +6 -0
- data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
- data/templates/deploy-to-cloudflare/README.md +84 -0
- data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
- data/templates/deploy-to-cloudflare/package.json +36 -0
- data/templates/deploy-to-cloudflare/src/index.js +445 -0
- data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
- data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
- data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
- data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
- data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
- data/templates/worker/README.md +180 -15
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2 -2
- data/templates/worker/package.json +1 -1
- data/templates/worker/src/index.js +331 -38
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +78 -20
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +50 -0
- metadata +49 -3
data/docs/mailboxes.md
CHANGED
|
@@ -5,7 +5,7 @@ mail into them, track read/archive state, and send through the durable outbox.
|
|
|
5
5
|
It works in one SQLite database or with a separate database per organization.
|
|
6
6
|
It supplies models and services; your app supplies permissions and the UI.
|
|
7
7
|
|
|
8
|
-
This module is available
|
|
8
|
+
This module is available since version 0.2.0. Install `gem "cloudflare-email", "~> 0.3.0"` for the latest features.
|
|
9
9
|
|
|
10
10
|
**Database multi-tenancy is off by default.** The mailbox generator works with
|
|
11
11
|
one ordinary database. Calling `for_tenant` groups and scopes mailbox records;
|
|
@@ -34,7 +34,8 @@ require "cloudflare/email/mailboxes"
|
|
|
34
34
|
Loading this module enables mailbox lookup on the gem's Cloudflare ingress.
|
|
35
35
|
Existing receiving addresses must be registered and activated before switching
|
|
36
36
|
an existing application over. Unregistered or suspended destinations return
|
|
37
|
-
HTTP 422 and the Worker rejects the delivery
|
|
37
|
+
HTTP 422 and the Worker rejects the delivery by default. You can explicitly
|
|
38
|
+
configure a domain catch-all below when your application needs that behavior.
|
|
38
39
|
|
|
39
40
|
For separate organization databases, follow the
|
|
40
41
|
[activerecord-tenanted setup](activerecord-tenanted.md) **before loading the
|
|
@@ -146,6 +147,67 @@ are explicit: the module does not automatically strip `+tags` or invent address
|
|
|
146
147
|
fallbacks. Reserve application-specific names such as Rebulk's `tracking` in
|
|
147
148
|
your mailbox-management policy before creation.
|
|
148
149
|
|
|
150
|
+
## Receive unregistered local parts with an optional catch-all
|
|
151
|
+
|
|
152
|
+
Catch-all receiving is **off by default** and is available in the 0.3
|
|
153
|
+
version. It lets one existing address receive otherwise unregistered addresses
|
|
154
|
+
on its exact domain, without creating an alias row for each incoming local part.
|
|
155
|
+
For example, `anything@acme.example.com` can arrive in an existing support mailbox.
|
|
156
|
+
It does not cover subdomains such as `anything@other.acme.example.com`.
|
|
157
|
+
|
|
158
|
+
Fresh installations include the schema. For an existing installation, generate
|
|
159
|
+
the optional upgrade and apply it to the database containing your mailbox tables:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
bin/rails generate cloudflare:email:mailboxes:catch_all
|
|
163
|
+
bin/rails db:migrate
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
With separate tenant databases, pass
|
|
167
|
+
`--tenant-migrations-path=db/tenant_migrate` and run your application's tenant
|
|
168
|
+
migration command for every affected tenant. Updating the gem alone keeps older
|
|
169
|
+
schemas working with exact-address routing. Enabling a catch-all without its
|
|
170
|
+
migration raises a configuration error.
|
|
171
|
+
|
|
172
|
+
First configure and verify the domain's catch-all Email Routing rule in Cloudflare
|
|
173
|
+
to send mail to your Worker. Then use your authorized provisioning code:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
Cloudflare::Email::Mailboxes.for_tenant("organization-123") do |account|
|
|
177
|
+
mailbox = account.mailboxes.find_by!(owner_ref: "team:42")
|
|
178
|
+
address = account.addresses(mailbox.id).find_by!(address: "support@acme.example.com")
|
|
179
|
+
account.enable_catch_all(address.id,
|
|
180
|
+
evidence: "Domain catch-all Worker route verified in setup ticket 57")
|
|
181
|
+
|
|
182
|
+
# Later, stop accepting unregistered local parts:
|
|
183
|
+
# account.disable_catch_all(address.id)
|
|
184
|
+
end
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
The address, mailbox and receiving domain must be active before enabling it.
|
|
188
|
+
The evidence is an operator assertion, as with ordinary address activation;
|
|
189
|
+
this call does not change Cloudflare rules. Only one active address per domain
|
|
190
|
+
can serve as a catch-all. A database constraint also protects concurrent changes.
|
|
191
|
+
Suspending that address pauses fallback. If another catch-all is enabled while
|
|
192
|
+
it is suspended, disable the old flag before reactivating the old address.
|
|
193
|
+
|
|
194
|
+
Exact registered addresses always take precedence. An exact address that is
|
|
195
|
+
pending, suspended, or belongs to a suspended mailbox is rejected; it does not
|
|
196
|
+
fall through to another mailbox. The catch-all also stops receiving when its
|
|
197
|
+
mailbox or domain is suspended.
|
|
198
|
+
|
|
199
|
+
Both `with_recipient` and `receive` yield `destination.catch_all == true` only
|
|
200
|
+
when fallback was used. `destination.recipient` remains the actual envelope
|
|
201
|
+
recipient, while `address_id` identifies the existing backing address. Stored
|
|
202
|
+
mailbox membership retains that actual recipient too. Apps can use this flag to
|
|
203
|
+
enforce additional rules, such as allowing fallback only while an organization
|
|
204
|
+
has one site, before accepting/persisting the message.
|
|
205
|
+
|
|
206
|
+
Catch-all receiving grants **no additional sending addresses**. Outbound From,
|
|
207
|
+
Sender and envelope sender must still match an exact active registered address.
|
|
208
|
+
Disable catch-all receiving through the API; the management UI displays its
|
|
209
|
+
configuration but does not enable or disable it.
|
|
210
|
+
|
|
149
211
|
## Read incoming mail
|
|
150
212
|
|
|
151
213
|
The Worker signs the SMTP recipient. Rails verifies the whole request, resolves
|
|
@@ -294,6 +356,8 @@ lookup. Drain existing ActionMailbox/ActiveStorage queues before enabling this
|
|
|
294
356
|
mode: previously serialized jobs without the new metadata are rejected.
|
|
295
357
|
|
|
296
358
|
The gem does not create organizations/users, verify customer domain ownership,
|
|
297
|
-
provide IMAP/POP, or supply
|
|
359
|
+
provide IMAP/POP, or supply a full compose/conversation inbox. The optional
|
|
360
|
+
[management engine](management-engine.md) supplies mailbox administration and
|
|
361
|
+
plain-text previews using your host's access policy. The original Rebulk document-processing
|
|
298
362
|
Worker contract and sender-review policy still need a separate application
|
|
299
363
|
migration; installing this module does not replace that live pipeline.
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# Optional mailbox management engine
|
|
2
|
+
|
|
3
|
+
The management engine is a small, server-rendered Rails interface for the
|
|
4
|
+
[managed mailbox APIs](mailboxes.md). It works with ordinary Rails apps and with
|
|
5
|
+
apps using Inertia, React, Turbo or another frontend. No JavaScript, Node build,
|
|
6
|
+
asset pipeline or frontend package is required for the engine.
|
|
7
|
+
|
|
8
|
+
This feature is available in RubyGems 0.3.0.
|
|
9
|
+
|
|
10
|
+
## What you get
|
|
11
|
+
|
|
12
|
+
- Mailbox lists, names, creation and aliases on allowed receiving domains.
|
|
13
|
+
- Address setup status and mailbox suspension/resumption.
|
|
14
|
+
- A paginated list of received messages and an escaped plain-text preview.
|
|
15
|
+
- Optional read/unread and archive/unarchive controls.
|
|
16
|
+
- Ordinary CSRF-protected forms, mobile layouts and bundled CSS.
|
|
17
|
+
|
|
18
|
+
Your app supplies login, permissions, domain entitlements and any organization
|
|
19
|
+
mapping. The engine does not create users, claim domains, edit DNS, provision
|
|
20
|
+
Cloudflare routes, send mail, or permanently delete records. The default creation
|
|
21
|
+
hooks create pending addresses. Your administrator or existing provisioning code
|
|
22
|
+
completes routing and activation through the same Ruby APIs.
|
|
23
|
+
|
|
24
|
+
The viewer never renders sender HTML, loads remote images, or provides raw MIME
|
|
25
|
+
or attachment downloads. It shows attachment names and up to 100,000 plain-text
|
|
26
|
+
characters. Compose, conversations, AI workflows and product search belong in
|
|
27
|
+
your application.
|
|
28
|
+
|
|
29
|
+
## Install and mount
|
|
30
|
+
|
|
31
|
+
First install the managed mailbox tables and enable the module using
|
|
32
|
+
[the mailbox setup guide](mailboxes.md). SQLite works; database tenancy remains
|
|
33
|
+
off unless your app configures it explicitly.
|
|
34
|
+
|
|
35
|
+
Require the engine **before Rails initializes**, after Bundler loads the gem:
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# config/application.rb, after Bundler.require(*Rails.groups)
|
|
39
|
+
require "cloudflare/email/management"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Configure a host adapter in an initializer:
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
# config/initializers/cloudflare_email_management.rb
|
|
46
|
+
Cloudflare::Email::Management.configure do |config|
|
|
47
|
+
# Resolve the class when handling the request, so Rails reloading works.
|
|
48
|
+
config.adapter = ->(controller) { EmailManagementAccess.new(controller) }
|
|
49
|
+
config.back_path = ->(controller) { controller.main_app.root_path }
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Mount it wherever it fits your application:
|
|
54
|
+
|
|
55
|
+
```ruby
|
|
56
|
+
# config/routes.rb
|
|
57
|
+
mount Cloudflare::Email::Management::Engine => "/email-management",
|
|
58
|
+
as: :email_management
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Installing the gem does not expose this interface automatically. Without a
|
|
62
|
+
configured adapter and enabled mailbox module the mounted interface returns 503.
|
|
63
|
+
The base adapter grants no access. Its public stylesheet endpoint serves only
|
|
64
|
+
the bundled CSS.
|
|
65
|
+
|
|
66
|
+
## Connect your authentication and ownership
|
|
67
|
+
|
|
68
|
+
Here is a single-database example using Rails' generated authentication convention
|
|
69
|
+
(`Session`, `Current.session`, and a signed `session_id` cookie). Match the cookie
|
|
70
|
+
and session lookup to your app; the agentic inbox example uses `session_token`.
|
|
71
|
+
Use the host authentication system's expiry, revocation and access policy.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
# app/services/email_management_access.rb
|
|
75
|
+
class EmailManagementAccess < Cloudflare::Email::Management::Adapter
|
|
76
|
+
ACTIONS = %i[index show create add_address suspend resume
|
|
77
|
+
show_message mark_read archive].freeze
|
|
78
|
+
|
|
79
|
+
def authenticate!
|
|
80
|
+
Current.session = Session.find_by(
|
|
81
|
+
id: controller.request.cookie_jar.signed[:session_id]
|
|
82
|
+
)
|
|
83
|
+
@user = Current.user
|
|
84
|
+
return true if @user
|
|
85
|
+
|
|
86
|
+
controller.redirect_to controller.main_app.new_session_path
|
|
87
|
+
false
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def tenant_key = "application"
|
|
91
|
+
def owner_ref = "User:#{@user.id}"
|
|
92
|
+
|
|
93
|
+
def mailboxes(session)
|
|
94
|
+
session.mailboxes.where(owner_ref: owner_ref)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def allowed?(action, mailbox = nil)
|
|
98
|
+
@user.present? && ACTIONS.include?(action) &&
|
|
99
|
+
(mailbox.nil? || mailbox.owner_ref == owner_ref)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def domains(_session)
|
|
103
|
+
["in.example.com"] # Only domains this user is entitled to use.
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def create_mailbox(session, name:, address:)
|
|
107
|
+
session.create(name: name, address: address, owner_ref: owner_ref)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For Devise, the authentication hook can use your configured Warden scope instead
|
|
113
|
+
of the cookie lookup, for example
|
|
114
|
+
`@user = controller.request.env["warden"]&.authenticate(scope: :user)`.
|
|
115
|
+
The engine inherits from `ActionController::Base`; host `ApplicationController`
|
|
116
|
+
callbacks and methods are not inherited. Make authentication explicit in the
|
|
117
|
+
adapter and return exactly `true` on success, or redirect to the host's login.
|
|
118
|
+
|
|
119
|
+
The directory must already contain the active receiving domain assigned to the
|
|
120
|
+
same registry key. The engine intersects `domains` with that directory and
|
|
121
|
+
intersects the host mailbox relation with the current gem tenant. Parameters
|
|
122
|
+
cannot select an owner or tenant.
|
|
123
|
+
|
|
124
|
+
Do not return every domain merely because it exists in the database. A customer
|
|
125
|
+
app should apply organization entitlement, reserved-name and signup policies.
|
|
126
|
+
For separate tenant databases, return a trusted host-resolved key from
|
|
127
|
+
`tenant_key` and configure the existing gem tenancy adapter and framework
|
|
128
|
+
connections before mailbox models load.
|
|
129
|
+
|
|
130
|
+
## Customize behavior without forking the pages
|
|
131
|
+
|
|
132
|
+
The adapter has these hooks:
|
|
133
|
+
|
|
134
|
+
| Hook | Contract |
|
|
135
|
+
| --- | --- |
|
|
136
|
+
| `authenticate!` | Authenticate the request; return exactly true or redirect/deny. |
|
|
137
|
+
| `tenant_key` | Trusted registry key; never use an unchecked request parameter. |
|
|
138
|
+
| `mailboxes(session)` | ActiveRecord relation containing mailboxes accessible to this principal. |
|
|
139
|
+
| `allowed?(action, mailbox = nil)` | Return exactly true to permit an action. |
|
|
140
|
+
| `domains(session)` | Array of domain strings permitted for this principal. |
|
|
141
|
+
| `create_mailbox(session, name:, address:)` | Return the created gem mailbox, visible in the host's scope. |
|
|
142
|
+
| `add_address(session, mailbox, address:)` | Add an address through your existing app provisioning service if needed. |
|
|
143
|
+
|
|
144
|
+
The adapter factory, authentication and tenant-key resolution run before the
|
|
145
|
+
mailbox context is entered; the host must establish any context its authentication
|
|
146
|
+
needs. Mailbox/domain scopes, permissions and mutation hooks run inside the
|
|
147
|
+
resolved mailbox context. Creation also runs in a
|
|
148
|
+
database transaction; a returned mailbox outside the host scope rolls back.
|
|
149
|
+
Keep network work out of creation hooks and enqueue provisioning after commit.
|
|
150
|
+
The default alias hook calls `session.add_address`.
|
|
151
|
+
|
|
152
|
+
Return false for `:create` or lifecycle actions to provide a restricted dashboard.
|
|
153
|
+
Use `:show_message` to control message access independently of metadata. Deny
|
|
154
|
+
`:mark_read` and `:archive` when the host product already owns a different
|
|
155
|
+
conversation-level read/folder model. Hidden controls also reject direct requests.
|
|
156
|
+
|
|
157
|
+
An application with its own mailbox record can override creation and alias hooks
|
|
158
|
+
to call that application's service and return the linked gem model. The agentic
|
|
159
|
+
inbox example does this: both UIs and automatic account provisioning share one
|
|
160
|
+
service, so engine-created mailboxes also appear in its full inbox.
|
|
161
|
+
|
|
162
|
+
## Inertia, React and Turbo applications
|
|
163
|
+
|
|
164
|
+
Use a normal HTML link or full-page navigation to the mount. For example:
|
|
165
|
+
|
|
166
|
+
```jsx
|
|
167
|
+
<a href="/email-management">Manage mailboxes</a>
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
The engine returns HTML, not Inertia responses. Its separate layout keeps host
|
|
171
|
+
frontend dependencies out of the gem. The optional `back_path` callback returns
|
|
172
|
+
a local absolute path for a Back to app link. Programmatic provisioning continues
|
|
173
|
+
to work whether this engine is mounted or not.
|
|
174
|
+
|
|
175
|
+
## Operational behavior
|
|
176
|
+
|
|
177
|
+
Suspension preserves mailbox records and retained mail while disabling mailbox
|
|
178
|
+
sending/receiving. Resuming does not activate pending addresses or repair DNS.
|
|
179
|
+
Read/archive state belongs to the gem mailbox memberships and does not implicitly
|
|
180
|
+
rewrite host conversation records.
|
|
181
|
+
|
|
182
|
+
Private pages use no-store caching, a restrictive CSP and a same-origin referrer
|
|
183
|
+
policy. Forms check both Rails CSRF tokens and origins. Serve the host over HTTPS,
|
|
184
|
+
configure trusted proxy headers correctly, and keep its session cookies secure.
|
|
185
|
+
Do not disable CSRF to work around a proxy configuration issue.
|
|
186
|
+
|
|
187
|
+
Verification includes real Rails sessions, owner/tenant scoping, all operation
|
|
188
|
+
permissions, CSRF/origin checks, rollback, escaped previews, retained raw source,
|
|
189
|
+
and eager-loading a core-only app without exposing the engine. The example also
|
|
190
|
+
tests desktop/mobile Chromium and JavaScript-disabled forms with synthetic mail.
|
|
@@ -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,68 @@
|
|
|
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
|
+
```sh
|
|
10
|
+
bin/rails cloudflare:email:check_route \
|
|
11
|
+
ADDRESS=houston@customer.example.com \
|
|
12
|
+
WORKER_NAME=cloudflare-email-ingress-production \
|
|
13
|
+
ACCOUNT_ID=your-cloudflare-account-id
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Configure your Cloudflare management token in the normal gem credentials. The
|
|
17
|
+
inspection needs Zone Read, DNS Read, and Email Routing Read permissions. No Edit
|
|
18
|
+
permissions are needed. `WORKER_NAME` defaults to the gem's environment-scoped
|
|
19
|
+
Worker name; `ACCOUNT_ID` falls back to the configured account, if present.
|
|
20
|
+
|
|
21
|
+
Every check reports `PASS`, `FAIL`, or `UNKNOWN`. The command exits zero only
|
|
22
|
+
when all configuration checks pass. An unavailable API, insufficient permission,
|
|
23
|
+
an unsupported matcher, or ambiguous rule ordering produces `UNKNOWN`, not a
|
|
24
|
+
successful result. Output excludes raw provider responses and credentials.
|
|
25
|
+
|
|
26
|
+
The check inspects:
|
|
27
|
+
|
|
28
|
+
- The closest containing Cloudflare zone and its account when an expected account is supplied.
|
|
29
|
+
- Whether Email Routing is enabled for the zone.
|
|
30
|
+
- MX and SPF records for the **exact receiving domain**. Parent-domain records
|
|
31
|
+
cannot satisfy a subdomain's requirements, and a parent's unrelated MX provider
|
|
32
|
+
does not conflict with correctly configured subdomain records.
|
|
33
|
+
- Active explicit address rules before falling back to the catch-all. An explicit
|
|
34
|
+
drop, forwarding action, or different Worker takes precedence over a correct
|
|
35
|
+
catch-all. Disabled explicit rules are ignored. Multiple matching rules or
|
|
36
|
+
unsupported matcher forms leave selection unverified rather than guessing
|
|
37
|
+
priority or tie behavior.
|
|
38
|
+
|
|
39
|
+
List reads are paginated and bounded to 50 pages per endpoint, responses to 1 MiB,
|
|
40
|
+
and each request to 20 seconds (with shorter connection/read timeouts). An
|
|
41
|
+
incomplete inspection does not report success. This command only issues GET
|
|
42
|
+
requests. It never enables routing, changes DNS, repairs rules, or activates
|
|
43
|
+
mailbox records.
|
|
44
|
+
|
|
45
|
+
## Use from Ruby
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
require "cloudflare/email/routing_diagnostics"
|
|
49
|
+
|
|
50
|
+
report = Cloudflare::Email::RoutingDiagnostics.new(
|
|
51
|
+
api_token: ENV.fetch("CLOUDFLARE_API_TOKEN")
|
|
52
|
+
).check(
|
|
53
|
+
address: "houston@customer.example.com",
|
|
54
|
+
worker_name: "cloudflare-email-ingress-production",
|
|
55
|
+
account_id: ENV.fetch("CLOUDFLARE_ACCOUNT_ID")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
report[:status] # "pass", "fail", or "unknown"
|
|
59
|
+
report[:checks] # [{ name: "zone", status: "pass", message: "..." }, ...]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
This API does not require Active Record or enable multi-tenancy. You can use it
|
|
63
|
+
with a custom Worker and an existing application ingestion pipeline. It reports
|
|
64
|
+
a provider configuration snapshot; it does **not** prove public DNS propagation,
|
|
65
|
+
full SPF evaluation, successful live delivery, the Worker's application URL or
|
|
66
|
+
behavior, or application acceptance. Keep those as separate checks, including a
|
|
67
|
+
controlled end-to-end delivery when appropriate. A passing report does not
|
|
68
|
+
automatically change mailbox readiness or constitute delivery evidence.
|
data/docs/troubleshooting.md
CHANGED
|
@@ -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
|
|
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.
|
|
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 |
|
|
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. |
|
data/docs/upgrading-0.2.md
CHANGED
|
@@ -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.
|