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
|
@@ -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
|
|
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)
|
|
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.
|
|
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.
|
data/docs/delivery-events.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
95
|
-
|
|
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
|
data/docs/getting-started.md
CHANGED
|
@@ -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.
|
|
22
|
-
[upgrade guide](upgrading-0.
|
|
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.
|
|
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.
|
|
127
|
-
|
|
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.
|
|
308
|
+
[the upgrade guide](upgrading-0.3.md) for an existing 0.1 installation.
|