cloudflare-email 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +14 -4
- data/app/controllers/cloudflare/email/ingress_controller.rb +1 -2
- data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +2 -166
- data/app/controllers/cloudflare/email/management/styles_controller.rb +2 -6
- data/docs/custom-ingress.md +5 -1
- data/docs/features.md +1 -1
- data/docs/getting-started.md +4 -3
- data/docs/mailboxes.md +8 -1
- data/docs/routing-diagnostics.md +7 -0
- data/docs/troubleshooting.md +1 -1
- data/docs/verification/2026-09-13-action-mailbox-core.md +96 -0
- data/lib/cloudflare/email/active_record/base.rb +3 -53
- data/lib/cloudflare/email/engine.rb +1 -13
- data/lib/cloudflare/email/envelope.rb +2 -12
- data/lib/cloudflare/email/error.rb +5 -12
- data/lib/cloudflare/email/ingress.rb +19 -8
- data/lib/cloudflare/email/mailboxes/configuration.rb +7 -15
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +1 -14
- data/lib/cloudflare/email/mailboxes/models.rb +8 -147
- data/lib/cloudflare/email/mailboxes/service.rb +7 -258
- data/lib/cloudflare/email/mailboxes.rb +1 -0
- data/lib/cloudflare/email/management/adapter.rb +3 -25
- data/lib/cloudflare/email/management/configuration.rb +4 -11
- data/lib/cloudflare/email/management/engine.rb +2 -0
- data/lib/cloudflare/email/tenancy.rb +2 -73
- data/lib/cloudflare/email/tenant_job_context.rb +2 -85
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare-email.rb +6 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +1 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +1 -1
- data/templates/deploy-to-cloudflare/README.md +21 -1
- data/templates/deploy-to-cloudflare/docs/domain-setup.md +198 -0
- data/templates/deploy-to-cloudflare/package.json +1 -0
- data/templates/deploy-to-cloudflare/scripts/check-subdomains.mjs +54 -0
- data/templates/deploy-to-cloudflare/test/subdomains.test.ts +51 -0
- data/templates/worker/README.md +6 -0
- data/templates/worker/docs/domain-setup.md +198 -0
- data/templates/worker/package.json +1 -0
- data/templates/worker/scripts/check-subdomains.mjs +54 -0
- data/templates/worker/test/subdomains.test.ts +51 -0
- metadata +22 -6
- data/app/views/cloudflare/email/management/mailboxes/index.html.erb +0 -65
- data/app/views/cloudflare/email/management/mailboxes/message.html.erb +0 -34
- data/app/views/cloudflare/email/management/mailboxes/show.html.erb +0 -86
- data/app/views/layouts/cloudflare/email/management.html.erb +0 -29
- data/lib/cloudflare/email/management/management.css +0 -68
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c65b29d22af39c25cb2affb776984bc9a9086bcad75c7388c2017692f61eb9ef
|
|
4
|
+
data.tar.gz: 2c0d1f15f4159e780257260af62cca8d414c7cc00d7c1c8b2677ac243cf81b04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4b76b7c08c99da504f7c90bbfb79f6f3130345e128c0fad9988a1449270d79019ffa5af8fb62a0759ad881c56019ffe6d9f6b967d13136e988b9e98ef6d7043e
|
|
7
|
+
data.tar.gz: d3f2d2573c6d41140ceff2395c4aa84194eb10ad2c56379df133f1fa1687424b009513800e4c67d1077584750146c2f5b9e0dfedfb53d2b9a351c5f207f80636
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 — 2026-09-14
|
|
4
|
+
|
|
5
|
+
Released alongside Mailbox Kit 0.1.0. Cloudflare installs the core automatically.
|
|
6
|
+
Existing installations should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md).
|
|
7
|
+
|
|
8
|
+
- Allow receiving domains without a sending account. The upgrade generator
|
|
9
|
+
preserves existing directory records while making account ownership optional.
|
|
10
|
+
|
|
11
|
+
- Index inbox memberships by inbound email for recipient isolation checks,
|
|
12
|
+
retention and purge. New schemas include the index; `mailbox_kit:upgrade`
|
|
13
|
+
generates a data-preserving migration for existing installations.
|
|
14
|
+
- Delegate shared configuration to Mailbox Kit and isolate Cloudflare session
|
|
15
|
+
additions in an integration module. Provide a short core quickstart plus
|
|
16
|
+
packaged integration and upgrade guides.
|
|
17
|
+
|
|
18
|
+
- Lean on Action Mailbox for original email records, processing and configurable
|
|
19
|
+
retention. Add idempotent attachment of existing Rails records and source-based
|
|
20
|
+
receiving that resolves duplicates for inbox membership. Share the persistence
|
|
21
|
+
bridge with Cloudflare while preserving its signed delivery identity and legacy
|
|
22
|
+
duplicate return value. Reuse Rails' model load hook for selective retention.
|
|
23
|
+
|
|
24
|
+
- Extract provider-neutral mailbox storage, recipient routing, optional tenancy,
|
|
25
|
+
raw-message retention, Rails job context and the server-rendered UI into the
|
|
26
|
+
sibling `mailbox-kit` gem. Cloudflare APIs, Worker transport, sending outbox and
|
|
27
|
+
delivery feedback remain in `cloudflare-email`. Preserve existing table names,
|
|
28
|
+
public constants, job payload keys and mounted Cloudflare engine routes.
|
|
29
|
+
See [Mailbox Kit](mailbox-kit/README.md) for standalone setup and release order.
|
|
30
|
+
|
|
31
|
+
- Add a cited one-time domain setup guide for both receiving domains and dynamic
|
|
32
|
+
organization subdomains, shared between Worker templates. Add a read-only
|
|
33
|
+
`npm run check:subdomains` DNS helper with named/fresh probes and explicit limits
|
|
34
|
+
on what DNS can verify. The Worker transport and gem routing behavior are unchanged.
|
|
35
|
+
|
|
3
36
|
## 0.3.0 — 2026-09-13
|
|
4
37
|
|
|
5
38
|
See [upgrading from 0.2](docs/upgrading-0.3.md) before deploying the updated Worker.
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Ruby client for [Cloudflare Email Service](https://developers.cloudflare.com/email-service/), with ActionMailer, authenticated ActionMailbox ingress, a forwarding Worker, and optional durable Rails sending and delivery-event tracking.
|
|
4
4
|
|
|
5
|
-
Version **0.
|
|
5
|
+
Version **0.4.0**, with **Mailbox Kit 0.1.0**. Ruby 3.2+, Rails 7.2–8.1; Ruby 4.0 is tested with Rails 8.1. Supported test floors are Rails 7.2.3.2, 8.0.5.1, and 8.1.3.1. Prefer a maintained Ruby/Rails release for new applications. Rails and database dependencies remain optional for the plain Ruby client. Node is optional: Worker deployment also works through the included Ruby deployer. See [security guidance](SECURITY.md) for deployment responsibilities.
|
|
6
6
|
|
|
7
7
|
[](https://deploy.workers.cloudflare.com/?url=https://github.com/cole-robertson/cloudflare-email/tree/main/templates/deploy-to-cloudflare)
|
|
8
8
|
|
|
@@ -10,6 +10,13 @@ Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [P
|
|
|
10
10
|
|
|
11
11
|
## Start here
|
|
12
12
|
|
|
13
|
+
**Starting in 0.4.0:** provider-neutral mailbox functionality lives in
|
|
14
|
+
the sibling [Mailbox Kit](mailbox-kit/README.md) gem. Existing Cloudflare setup
|
|
15
|
+
continues to work; the core requires no Cloudflare account. Installing
|
|
16
|
+
`cloudflare-email` also installs `mailbox-kit` 0.1.x.
|
|
17
|
+
Existing mailbox installations should use the [core upgrade guide](mailbox-kit/docs/upgrading.md)
|
|
18
|
+
to add the inbound lookup index without recreating their tables.
|
|
19
|
+
|
|
13
20
|
**Sending email?** Start with the [step-by-step Rails guide](docs/getting-started.md).
|
|
14
21
|
**Building a mailbox?** Follow the same guide through receiving, the SQLite-compatible outbox, and delivery tracking.
|
|
15
22
|
**Using plain Ruby?** Jump to [Plain Ruby](#plain-ruby); Rails and a database are optional.
|
|
@@ -18,6 +25,8 @@ Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [P
|
|
|
18
25
|
| --- | --- |
|
|
19
26
|
| [Features at a glance](docs/features.md) | Everything the gem handles, and what your app supplies |
|
|
20
27
|
| [Getting started](docs/getting-started.md) | Install, send your first email, receive replies, and save reliable send operations |
|
|
28
|
+
| [Cloudflare domain setup](templates/worker/docs/domain-setup.md) | Configure once for many mailboxes or dynamic organization subdomains, with a Rebulk example and DNS checks |
|
|
29
|
+
| [Hello-world Rails template](https://github.com/cole-robertson/cloudflare-email-rails-starter) | Clone a minimal SQLite mailbox app using the published gem and its management UI |
|
|
21
30
|
| [Troubleshooting](docs/troubleshooting.md) | What to check when mail or delivery updates do not arrive |
|
|
22
31
|
| [Managed mailboxes](docs/mailboxes.md) | Create inboxes and aliases, read/archive mail, and send from a mailbox |
|
|
23
32
|
| [Management engine](docs/management-engine.md) | Mount an optional server-rendered mailbox UI using your app's authentication |
|
|
@@ -29,16 +38,17 @@ Deploy the inbound Worker with guided R2/Queue provisioning and secret setup. [P
|
|
|
29
38
|
| [SQLite tenant databases](docs/activerecord-tenanted.md) | Give each organization its own SQLite database with `activerecord-tenanted` |
|
|
30
39
|
| [Durable outbox](docs/outbox.md) | Detailed setup, callbacks, retries, and recovery |
|
|
31
40
|
| [Delivery events](docs/delivery-events.md) | Cloudflare Queue setup and recipient status tracking |
|
|
32
|
-
| [Upgrading to 0.
|
|
41
|
+
| [Upgrading to 0.4](mailbox-kit/docs/upgrading.md) | Core extraction and data-preserving migrations for existing mailboxes |
|
|
42
|
+
| [Upgrading from 0.2](docs/upgrading-0.3.md) | Additional changes before deploying the durable Worker |
|
|
33
43
|
|
|
34
44
|
The sections below are the configuration and API reference.
|
|
35
45
|
|
|
36
46
|
## Install and send from Rails
|
|
37
47
|
|
|
38
|
-
Add version 0.
|
|
48
|
+
Add version 0.4 to your Gemfile. Existing mailbox users should follow the [core upgrade guide](mailbox-kit/docs/upgrading.md). When upgrading from 0.2, also follow the [durable Worker upgrade guide](docs/upgrading-0.3.md):
|
|
39
49
|
|
|
40
50
|
```ruby
|
|
41
|
-
gem "cloudflare-email", "~> 0.
|
|
51
|
+
gem "cloudflare-email", "~> 0.4.0"
|
|
42
52
|
```
|
|
43
53
|
|
|
44
54
|
```sh
|
|
@@ -35,9 +35,8 @@ module Cloudflare
|
|
|
35
35
|
head :payload_too_large
|
|
36
36
|
when :ok
|
|
37
37
|
inbound = if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?
|
|
38
|
-
recipient = result.message.envelope.fetch("to")
|
|
39
38
|
begin
|
|
40
|
-
|
|
39
|
+
result.message.receive_into_mailbox!
|
|
41
40
|
rescue Cloudflare::Email::Mailboxes::Unavailable
|
|
42
41
|
payload[:result] = :unavailable_mailbox
|
|
43
42
|
next head(:unprocessable_entity)
|
|
@@ -1,172 +1,8 @@
|
|
|
1
|
+
require File.join(MailboxKit::ROOT, "app/controllers/mailbox_kit/management/mailboxes_controller.rb")
|
|
1
2
|
module Cloudflare
|
|
2
3
|
module Email
|
|
3
4
|
module Management
|
|
4
|
-
class MailboxesController <
|
|
5
|
-
layout "layouts/cloudflare/email/management"
|
|
6
|
-
protect_from_forgery with: :exception
|
|
7
|
-
self.forgery_protection_origin_check = true
|
|
8
|
-
around_action :with_host_context
|
|
9
|
-
helper_method :allowed?, :host_back_path
|
|
10
|
-
|
|
11
|
-
class Denied < StandardError; end
|
|
12
|
-
class InvalidInput < StandardError; end
|
|
13
|
-
|
|
14
|
-
rescue_from Denied do
|
|
15
|
-
head :forbidden
|
|
16
|
-
end
|
|
17
|
-
rescue_from "ActiveRecord::RecordNotFound" do
|
|
18
|
-
head :not_found
|
|
19
|
-
end
|
|
20
|
-
rescue_from InvalidInput, "ActiveRecord::RecordInvalid", Cloudflare::Email::Error do
|
|
21
|
-
redirect_to(@mailbox ? mailbox_path(@mailbox) : root_path,
|
|
22
|
-
alert: "The changes could not be saved. Check the address, registered domain and required fields.",
|
|
23
|
-
status: :see_other)
|
|
24
|
-
end
|
|
25
|
-
rescue_from Cloudflare::Email::ConfigurationError do
|
|
26
|
-
head :service_unavailable
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def index
|
|
30
|
-
authorize!(:index)
|
|
31
|
-
@mailboxes = page(scoped_mailboxes)
|
|
32
|
-
@domains = permitted_domains if allowed?(:create)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def show
|
|
36
|
-
load_mailbox!(:show)
|
|
37
|
-
@addresses = @session.addresses(@mailbox.id).order(:id).to_a
|
|
38
|
-
@domains = allowed?(:add_address, @mailbox) ? permitted_domains : []
|
|
39
|
-
@messages = allowed?(:show_message, @mailbox) ? page(@session.messages(@mailbox.id)) : []
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def create
|
|
43
|
-
authorize!(:create)
|
|
44
|
-
input = params.require(:mailbox)
|
|
45
|
-
raise InvalidInput unless input.is_a?(ActionController::Parameters)
|
|
46
|
-
values = input.permit(:name, :address)
|
|
47
|
-
name = values[:name].to_s.strip
|
|
48
|
-
raise InvalidInput unless (1..255).cover?(name.length)
|
|
49
|
-
address = permitted_address!(values[:address])
|
|
50
|
-
Mailboxes::Mailbox.transaction do
|
|
51
|
-
@mailbox = @adapter.create_mailbox(@session, name: name, address: address)
|
|
52
|
-
# A hook cannot turn an arbitrary return value into a readable mailbox.
|
|
53
|
-
@mailbox = scoped_mailboxes.find(@mailbox.id)
|
|
54
|
-
end
|
|
55
|
-
redirect_to mailbox_path(@mailbox), notice: "Mailbox created. Review address setup below.", status: :see_other
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def add_address
|
|
59
|
-
load_mailbox!(:add_address)
|
|
60
|
-
@adapter.add_address(@session, @mailbox, address: permitted_address!(params[:address]))
|
|
61
|
-
redirect_to mailbox_path(@mailbox), notice: "Address added. Review its setup status.", status: :see_other
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def suspend
|
|
65
|
-
load_mailbox!(:suspend)
|
|
66
|
-
@session.suspend(@mailbox.id)
|
|
67
|
-
redirect_to mailbox_path(@mailbox), notice: "Mailbox suspended. Messages are retained.", status: :see_other
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def resume
|
|
71
|
-
load_mailbox!(:resume)
|
|
72
|
-
@session.resume(@mailbox.id)
|
|
73
|
-
redirect_to mailbox_path(@mailbox), notice: "Mailbox resumed. Pending addresses still need setup.", status: :see_other
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
def message
|
|
77
|
-
load_mailbox!(:show_message)
|
|
78
|
-
@entry = @session.messages(@mailbox.id).find(params[:message_id])
|
|
79
|
-
raw = @session.inbound_email(@mailbox.id, @entry.id).mail
|
|
80
|
-
@subject = raw.subject.to_s
|
|
81
|
-
@from = Array(raw.from).join(", ")
|
|
82
|
-
part = raw.text_part || (raw.mime_type == "text/plain" ? raw : nil)
|
|
83
|
-
@body = part ? part.decoded.to_s.encode("UTF-8", invalid: :replace, undef: :replace).first(100_000) :
|
|
84
|
-
"This message has no plain-text body. HTML rendering is disabled in this management interface."
|
|
85
|
-
@attachments = raw.attachments.map { |attachment| attachment.filename.to_s }
|
|
86
|
-
render :message
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def mark_read
|
|
90
|
-
load_mailbox!(:mark_read)
|
|
91
|
-
@session.mark_read(@mailbox.id, params[:message_id], read: boolean_param(:read))
|
|
92
|
-
redirect_to mailbox_path(@mailbox), status: :see_other
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def archive
|
|
96
|
-
load_mailbox!(:archive)
|
|
97
|
-
@session.archive(@mailbox.id, params[:message_id], archived: boolean_param(:archived))
|
|
98
|
-
redirect_to mailbox_path(@mailbox), status: :see_other
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
private
|
|
102
|
-
|
|
103
|
-
def with_host_context
|
|
104
|
-
response.headers["Cache-Control"] = "no-store"
|
|
105
|
-
response.headers["Referrer-Policy"] = "same-origin"
|
|
106
|
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
107
|
-
response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
|
|
108
|
-
factory = Management.configuration.adapter
|
|
109
|
-
unless factory.respond_to?(:call) && defined?(Mailboxes) && Mailboxes.enabled?
|
|
110
|
-
return head :service_unavailable
|
|
111
|
-
end
|
|
112
|
-
@adapter = factory.call(self)
|
|
113
|
-
authenticated = @adapter.authenticate!
|
|
114
|
-
return if performed?
|
|
115
|
-
return head :unauthorized unless authenticated == true
|
|
116
|
-
Mailboxes.for_tenant(@adapter.tenant_key) do |session|
|
|
117
|
-
@session = session
|
|
118
|
-
yield
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def allowed?(action, mailbox = nil)
|
|
123
|
-
@adapter.allowed?(action, mailbox) == true
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def authorize!(action, mailbox = nil)
|
|
127
|
-
raise Denied unless allowed?(action, mailbox)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def scoped_mailboxes
|
|
131
|
-
# Always retain the gem's tenant condition even if a host scope omits it.
|
|
132
|
-
@session.mailboxes.where(id: @adapter.mailboxes(@session).reselect(:id))
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
def load_mailbox!(action)
|
|
136
|
-
@mailbox = scoped_mailboxes.find(params[:id])
|
|
137
|
-
authorize!(action, @mailbox)
|
|
138
|
-
end
|
|
139
|
-
|
|
140
|
-
def permitted_domains
|
|
141
|
-
granted = Array(@adapter.domains(@session)).map(&:to_s)
|
|
142
|
-
Mailboxes::ReceivingDomain.active.where(tenant_key: @session.tenant_key, domain: granted).order(:domain).pluck(:domain)
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def permitted_address!(value)
|
|
146
|
-
address = Mailboxes.canonical_address(value)
|
|
147
|
-
raise InvalidInput unless permitted_domains.include?(address.split("@", 2).last)
|
|
148
|
-
address
|
|
149
|
-
end
|
|
150
|
-
|
|
151
|
-
def page(relation)
|
|
152
|
-
cursor = params[:after].to_s
|
|
153
|
-
raise InvalidInput unless cursor.empty? || cursor.match?(/\A[0-9]{1,18}\z/)
|
|
154
|
-
rows = relation.where("id > ?", cursor.to_i).order(:id).limit(51).to_a
|
|
155
|
-
@next_cursor = rows.length > 50 ? rows[49].id : nil
|
|
156
|
-
rows.first(50)
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
def boolean_param(key)
|
|
160
|
-
value = params[key]
|
|
161
|
-
raise InvalidInput unless %w[true false].include?(value)
|
|
162
|
-
value == "true"
|
|
163
|
-
end
|
|
164
|
-
|
|
165
|
-
def host_back_path
|
|
166
|
-
callback = Management.configuration.back_path
|
|
167
|
-
value = callback.call(self) if callback.respond_to?(:call)
|
|
168
|
-
value if value.is_a?(String) && value.start_with?("/") && !value.start_with?("//") && !value.match?(/[\\\r\n]/)
|
|
169
|
-
end
|
|
5
|
+
class MailboxesController < MailboxKit::Management::MailboxesController
|
|
170
6
|
end
|
|
171
7
|
end
|
|
172
8
|
end
|
|
@@ -1,12 +1,8 @@
|
|
|
1
|
+
require File.join(MailboxKit::ROOT, "app/controllers/mailbox_kit/management/styles_controller.rb")
|
|
1
2
|
module Cloudflare
|
|
2
3
|
module Email
|
|
3
4
|
module Management
|
|
4
|
-
class StylesController <
|
|
5
|
-
def show
|
|
6
|
-
response.headers["X-Content-Type-Options"] = "nosniff"
|
|
7
|
-
send_data File.binread(File.expand_path("../../../../../lib/cloudflare/email/management/management.css", __dir__)),
|
|
8
|
-
type: "text/css; charset=utf-8", disposition: "inline"
|
|
9
|
-
end
|
|
5
|
+
class StylesController < MailboxKit::Management::StylesController
|
|
10
6
|
end
|
|
11
7
|
end
|
|
12
8
|
end
|
data/docs/custom-ingress.md
CHANGED
|
@@ -42,6 +42,10 @@ class InboundEmailsController < ActionController::API
|
|
|
42
42
|
|
|
43
43
|
verified = result.message
|
|
44
44
|
|
|
45
|
+
# If no custom acceptance/processing record is needed, this is sufficient:
|
|
46
|
+
# verified.receive_into_mailbox!
|
|
47
|
+
# It also repairs missing membership on duplicate delivery without rerouting.
|
|
48
|
+
|
|
45
49
|
Cloudflare::Email::Mailboxes.receive(
|
|
46
50
|
recipient: verified.envelope.fetch("to")
|
|
47
51
|
) do |destination|
|
|
@@ -64,7 +68,7 @@ Mount this controller at your own route and point your existing Worker there. Th
|
|
|
64
68
|
|
|
65
69
|
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
70
|
|
|
67
|
-
`persist_action_mailbox!`
|
|
71
|
+
`persist_action_mailbox!` delegates verified raw bytes and metadata to the core's Rails persistence bridge. It preserves the existing new-record-or-`nil` return convention; the block example therefore does no new work for a duplicate. The default `receive_into_mailbox!` bridge also attaches the existing record, repairing a missing membership without reprocessing. Rails schedules normal routing only for newly created records after the transaction commits. Apply policies that must stop processing before calling persistence; a check performed after receiving returns can be too late.
|
|
68
72
|
|
|
69
73
|
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
74
|
|
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.4.0**, which includes **Mailbox Kit 0.1.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
|
|
data/docs/getting-started.md
CHANGED
|
@@ -18,11 +18,12 @@ 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
|
|
21
|
+
Add version 0.4 to your app's `Gemfile`. Existing mailbox installations should read
|
|
22
|
+
the [core upgrade guide](../mailbox-kit/docs/upgrading.md). When upgrading from 0.2,
|
|
23
|
+
also read the [Worker upgrade guide](upgrading-0.3.md):
|
|
23
24
|
|
|
24
25
|
```ruby
|
|
25
|
-
gem "cloudflare-email", "~> 0.
|
|
26
|
+
gem "cloudflare-email", "~> 0.4.0"
|
|
26
27
|
```
|
|
27
28
|
|
|
28
29
|
```sh
|
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 since version 0.2.0. Install `gem "cloudflare-email", "~> 0.
|
|
8
|
+
This module is available since version 0.2.0. Install `gem "cloudflare-email", "~> 0.4.0"` for the latest features, including the provider-neutral Mailbox Kit core.
|
|
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;
|
|
@@ -45,6 +45,13 @@ use the same tenant connection as the mailbox tables for atomic incoming storage
|
|
|
45
45
|
|
|
46
46
|
## Register an organization's domain
|
|
47
47
|
|
|
48
|
+
For addresses such as `invoices@acme.in.example.com`, follow the
|
|
49
|
+
[Cloudflare domain setup guide](../templates/worker/docs/domain-setup.md).
|
|
50
|
+
It covers Rebulk's existing wildcard receiving pattern, one-time infrastructure
|
|
51
|
+
verification, and exact organization-domain registration in Rails. The gem does
|
|
52
|
+
not require a per-organization Worker allowlist or Cloudflare approval once that
|
|
53
|
+
receiving infrastructure is established; new accounts must verify the provider behavior.
|
|
54
|
+
|
|
48
55
|
Run directory management from your authorized administration/provisioning code.
|
|
49
56
|
Do not expose arbitrary domain claims to customers without ownership checks.
|
|
50
57
|
|
data/docs/routing-diagnostics.md
CHANGED
|
@@ -6,6 +6,13 @@ A mailbox registered in Rails is not proof that Cloudflare can deliver to it.
|
|
|
6
6
|
Use this read-only check when onboarding a receiving domain, investigating a
|
|
7
7
|
missing message, or checking an existing catch-all before activating addresses.
|
|
8
8
|
|
|
9
|
+
For wildcard organization subdomains, also use the
|
|
10
|
+
[domain setup guide and public DNS checker](../templates/worker/docs/domain-setup.md).
|
|
11
|
+
This API inspects exact-domain configured records and does not resolve wildcard
|
|
12
|
+
inheritance. Missing exact records can therefore coexist with working inherited
|
|
13
|
+
MX. Public DNS and real delivery checks provide separate evidence; this diagnostic
|
|
14
|
+
is not a requirement to manually onboard every organization in Cloudflare.
|
|
15
|
+
|
|
9
16
|
```sh
|
|
10
17
|
bin/rails cloudflare:email:check_route \
|
|
11
18
|
ADDRESS=houston@customer.example.com \
|
data/docs/troubleshooting.md
CHANGED
|
@@ -9,7 +9,7 @@ into public logs or issues.
|
|
|
9
9
|
|
|
10
10
|
| What you see | What to check next |
|
|
11
11
|
| --- | --- |
|
|
12
|
-
| New generator or API is missing | Check `Gemfile.lock`.
|
|
12
|
+
| New generator or API is missing | Check `Gemfile.lock`. Use cloudflare-email 0.4.x with mailbox-kit 0.1.x; follow the [installation guide](getting-started.md#install-the-current-code). |
|
|
13
13
|
| Credentials appear to be ignored | Nonempty Rails credentials override environment variables. Check the Rails environment and restart the app after changes. |
|
|
14
14
|
| Authentication or domain error | Run `bin/rails cloudflare:email:doctor`; check account ID, token permissions and sending-domain verification. |
|
|
15
15
|
| `doctor` reports limited read access | A send token may lack diagnostic read permissions. Review the specific result; diagnostics alone cannot prove whether sending works. |
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Action Mailbox composition verification
|
|
2
|
+
|
|
3
|
+
## Independent review follow-up
|
|
4
|
+
|
|
5
|
+
The independent architecture review of `63660eb` confirmed the Rails/core/provider
|
|
6
|
+
split and reproduced a missing inbound membership index with SQLite's query planner.
|
|
7
|
+
The follow-up adds that index to both installers and supplies `mailbox_kit:upgrade`
|
|
8
|
+
for existing schemas. The generator fixture verifies indexed lookup, existing
|
|
9
|
+
membership preservation and repeat application. It passes 4 tests / 27 assertions.
|
|
10
|
+
|
|
11
|
+
Cloudflare configuration now delegates shared validation to the core, and its
|
|
12
|
+
session behavior is an explicit integration module. Handler overrides retain
|
|
13
|
+
their existing extension point. The short core README links to packaged
|
|
14
|
+
integration and upgrade guides.
|
|
15
|
+
|
|
16
|
+
After these changes, the full suite passes 289 tests / 1,151 assertions. Package
|
|
17
|
+
verification passes for Rails-free Cloudflare, packaged ingress/outbound/tenancy,
|
|
18
|
+
and an independent Rails consumer without Cloudflare in its bundle.
|
|
19
|
+
|
|
20
|
+
This is local/package verification, not a Rebulk production rollout. Automatic
|
|
21
|
+
model-owner lifecycle bindings and a universal outbound adapter framework remain
|
|
22
|
+
outside this change. Existing application acceptance, ownership and business
|
|
23
|
+
processing claims must be retained.
|
|
24
|
+
|
|
25
|
+
Mailbox Kit now treats `ActionMailbox::InboundEmail` as the original email record.
|
|
26
|
+
The kit's `Message` remains an inbox membership with read/archive state; no new
|
|
27
|
+
raw-email table, parser, routing job or processing-status model was introduced.
|
|
28
|
+
|
|
29
|
+
## Verified behavior
|
|
30
|
+
|
|
31
|
+
- `Mailboxes.receive(recipient:, source:)` resolves the trusted receiving scope
|
|
32
|
+
before Rails persistence, and attaches the existing record on source replay.
|
|
33
|
+
Identical source in two inboxes of one tenant produces one Rails email, two
|
|
34
|
+
memberships and one routing job. Read state remains independent per inbox.
|
|
35
|
+
- Identical source in different tenants sharing a database produces distinct
|
|
36
|
+
Rails records/jobs. `Session#attach` rejects a row already associated with a
|
|
37
|
+
different tenant, and resolves IDs only inside the selected connection.
|
|
38
|
+
- `Session#attach(recipient:, inbound_email_id:)` supports already-stored Rails
|
|
39
|
+
mail. It is idempotent per mailbox/email, preserves alias reservations and
|
|
40
|
+
refuses suspended addresses or missing records.
|
|
41
|
+
- The stock Rails Postmark ingress was exercised through HTTP in the isolated
|
|
42
|
+
Rails fixture. Duplicate webhook payloads produced one Rails email; explicit
|
|
43
|
+
host-authorized attachment created its inbox membership without reprocessing.
|
|
44
|
+
- Cloudflare retains its prior authenticated envelope/metadata identity and
|
|
45
|
+
stable Message-ID fallback. Its controller uses `receive_into_mailbox!` and
|
|
46
|
+
repairs missing membership on replay without re-enqueueing Rails routing.
|
|
47
|
+
The older `persist_action_mailbox!` still returns nil on duplicate delivery.
|
|
48
|
+
- Rails' `incinerate = false` prevents automatic cleanup scheduling without a
|
|
49
|
+
membership. With normal cleanup enabled, aged processed unassociated mail is
|
|
50
|
+
removed while inbox-associated mail survives. The guard uses Rails' public
|
|
51
|
+
inbound-email load hook and the same row lock as attachment and purge.
|
|
52
|
+
- Removing one of two memberships preserves the shared raw email; removing the
|
|
53
|
+
last through explicit purge removes it. Failed membership persistence rolls
|
|
54
|
+
back Rails database records and prevents a routing enqueue.
|
|
55
|
+
- PostgreSQL deliveries were synchronized at Rails creation to force a unique
|
|
56
|
+
conflict. Both calls completed with one inbound record, one membership, one
|
|
57
|
+
Active Storage blob record and one routing job. A savepoint rollback keeps the
|
|
58
|
+
losing transaction usable before looking up the winning record.
|
|
59
|
+
|
|
60
|
+
## Checks
|
|
61
|
+
|
|
62
|
+
- Full Ruby suite: 289 top-level tests, 1,151 assertions passed. Integration
|
|
63
|
+
subprocesses exercise the additional Rails behavior rather than inflating these
|
|
64
|
+
top-level counts.
|
|
65
|
+
- `script/verify_package.rb`: both gem archives built; Rails-free Cloudflare
|
|
66
|
+
consumer, packaged Cloudflare integration and independent core-only Rails
|
|
67
|
+
consumer passed. The independent bundle contains no Cloudflare gem.
|
|
68
|
+
- PostgreSQL inbound suite: 25 tests, 273 assertions passed on a disposable local
|
|
69
|
+
PostgreSQL 15 instance. CI runs the same suite against PostgreSQL 16 alongside
|
|
70
|
+
the existing outbox tests.
|
|
71
|
+
- SQLite core/compatibility, separate-tenant ingress and custom ingress regression
|
|
72
|
+
suites were exercised locally. CI covers the supported Ruby/Rails matrix.
|
|
73
|
+
|
|
74
|
+
Run the PostgreSQL check only against a disposable database:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
BUNDLE_GEMFILE=gemfiles/postgres.gemfile \
|
|
78
|
+
POSTGRES_TEST_URL=postgres://localhost/cloudflare_email_test \
|
|
79
|
+
bundle exec ruby script/verify_postgres_inbound.rb
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The script creates a unique schema and drops that schema when its fixture exits.
|
|
83
|
+
|
|
84
|
+
## Limits
|
|
85
|
+
|
|
86
|
+
The Postmark test is a fixed, authorized single-database route, not a live
|
|
87
|
+
Postmark deployment or a complete dynamic-tenant provider adapter. The caller
|
|
88
|
+
must authenticate ingress and authorize the envelope recipient and inbound ID;
|
|
89
|
+
MIME To/X-Original-To headers are not tenant entitlement. Tenant selection after
|
|
90
|
+
ingress cannot relocate raw mail already persisted to another database.
|
|
91
|
+
|
|
92
|
+
Raw blob uploads and external side effects are not made transactional by Active
|
|
93
|
+
Record. This check does not establish exactly-once business effects, provider
|
|
94
|
+
outage recovery, lost-job recovery, or live delivery. Existing Cloudflare durable
|
|
95
|
+
transport remains responsible for its outage/retry behavior. Owner lifecycle
|
|
96
|
+
macros and a general outbound adapter framework are separate work.
|
|
@@ -1,60 +1,10 @@
|
|
|
1
|
-
require "active_record"
|
|
2
1
|
require "cloudflare/email/tenancy"
|
|
3
|
-
|
|
2
|
+
require "mailbox_kit/active_record/base"
|
|
4
3
|
module Cloudflare
|
|
5
4
|
module Email
|
|
6
5
|
module ActiveRecord
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# connection using Rails' error hierarchy so boot can recover, while
|
|
10
|
-
# retaining the same fail-closed guard for every caller.
|
|
11
|
-
class TenantConnectionUnavailable < ::ActiveRecord::ConnectionNotEstablished; end
|
|
12
|
-
|
|
13
|
-
# Uses the host's abstract tenant connection owner when explicitly configured.
|
|
14
|
-
class Base < Tenancy.model_base(::ActiveRecord::Base)
|
|
15
|
-
self.abstract_class = true
|
|
16
|
-
|
|
17
|
-
class << self
|
|
18
|
-
def connection_pool
|
|
19
|
-
if Tenancy.enabled?
|
|
20
|
-
begin
|
|
21
|
-
Tenancy.require_context!
|
|
22
|
-
rescue ConfigurationError => error
|
|
23
|
-
raise TenantConnectionUnavailable, error.message
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
super
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
before_validation :verify_cloudflare_email_tenant!
|
|
31
|
-
before_save :verify_cloudflare_email_tenant!
|
|
32
|
-
before_destroy :verify_cloudflare_email_tenant!
|
|
33
|
-
|
|
34
|
-
# These methods can bypass callbacks or load a different row with the same ID.
|
|
35
|
-
%i[reload update_columns delete touch increment! decrement! association].each do |method_name|
|
|
36
|
-
define_method(method_name) do |*args, **kwargs, &block|
|
|
37
|
-
verify_cloudflare_email_tenant!
|
|
38
|
-
super(*args, **kwargs, &block)
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
private
|
|
43
|
-
|
|
44
|
-
# Both new construction and persisted-row instantiation call this before
|
|
45
|
-
# assigning inverse associations (which precede after_initialize).
|
|
46
|
-
def init_internals
|
|
47
|
-
super
|
|
48
|
-
@cloudflare_email_tenant_key = Tenancy.require_context! if Tenancy.enabled?
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def verify_cloudflare_email_tenant!
|
|
52
|
-
return unless Tenancy.enabled?
|
|
53
|
-
unless @cloudflare_email_tenant_key == Tenancy.require_context!
|
|
54
|
-
raise ConfigurationError, "record belongs to a different tenant context"
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
end
|
|
6
|
+
Base = MailboxKit::ActiveRecord::Base
|
|
7
|
+
TenantConnectionUnavailable = MailboxKit::ActiveRecord::TenantConnectionUnavailable
|
|
58
8
|
end
|
|
59
9
|
end
|
|
60
10
|
end
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "rails/engine"
|
|
2
|
+
require "mailbox_kit/railtie"
|
|
2
3
|
require "cloudflare/email/dev_ingress_guard"
|
|
3
4
|
|
|
4
5
|
# Register the delivery method at engine load time (not inside an initializer)
|
|
@@ -18,19 +19,6 @@ module Cloudflare
|
|
|
18
19
|
app.middleware.insert_before 0, DevIngressGuard if Rails.env.development?
|
|
19
20
|
end
|
|
20
21
|
|
|
21
|
-
config.to_prepare do
|
|
22
|
-
if (defined?(Cloudflare::Email::Tenancy) && Cloudflare::Email::Tenancy.enabled?) ||
|
|
23
|
-
(defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?)
|
|
24
|
-
require "cloudflare/email/tenant_job_context"
|
|
25
|
-
Cloudflare::Email::TenantJobContext.install_framework_jobs!
|
|
26
|
-
end
|
|
27
|
-
if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled? && defined?(::ActionMailbox::Engine)
|
|
28
|
-
require "cloudflare/email/mailboxes/inbound_retention"
|
|
29
|
-
::ActionMailbox::InboundEmail.prepend(Cloudflare::Email::Mailboxes::InboundRetention) unless
|
|
30
|
-
::ActionMailbox::InboundEmail.ancestors.include?(Cloudflare::Email::Mailboxes::InboundRetention)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
22
|
config.before_initialize do
|
|
35
23
|
unless defined?(Cloudflare::Email::Management::Engine)
|
|
36
24
|
Rails.autoloaders.main.ignore(
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
require "mailbox_kit/address_syntax"
|
|
1
2
|
require "json"
|
|
2
3
|
require "cloudflare/email/signing"
|
|
3
4
|
|
|
@@ -8,19 +9,8 @@ module Cloudflare
|
|
|
8
9
|
module Envelope
|
|
9
10
|
METADATA_KEY = "cloudflare_email_envelope".freeze
|
|
10
11
|
MAX_ENCODED_BYTES = 1024
|
|
11
|
-
LOCAL_PART = /\A[A-Za-z0-9.!#$%&'*+\/=\?^_`{|}~-]+\z/
|
|
12
|
-
DOMAIN_LABEL = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
|
|
13
|
-
|
|
14
12
|
def self.valid_address?(address, allow_empty: false)
|
|
15
|
-
|
|
16
|
-
return true if allow_empty && address.empty?
|
|
17
|
-
return false if address.bytesize > 254
|
|
18
|
-
parts = address.split("@", -1)
|
|
19
|
-
return false unless parts.size == 2
|
|
20
|
-
local, domain = parts
|
|
21
|
-
local.bytesize <= 64 && LOCAL_PART.match?(local) &&
|
|
22
|
-
!local.start_with?(".") && !local.end_with?(".") && !local.include?("..") &&
|
|
23
|
-
domain.split(".", -1).all? { |label| DOMAIN_LABEL.match?(label) }
|
|
13
|
+
MailboxKit::AddressSyntax.valid_address?(address, allow_empty: allow_empty)
|
|
24
14
|
end
|
|
25
15
|
|
|
26
16
|
def self.valid?(value)
|
|
@@ -1,18 +1,11 @@
|
|
|
1
|
+
require "mailbox_kit/error"
|
|
2
|
+
|
|
1
3
|
module Cloudflare
|
|
2
4
|
module Email
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def initialize(message = nil, status: nil, response: nil)
|
|
7
|
-
super(message)
|
|
8
|
-
@status = status
|
|
9
|
-
@response = response
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
class ConfigurationError < Error; end
|
|
5
|
+
Error = MailboxKit::Error
|
|
6
|
+
ConfigurationError = MailboxKit::ConfigurationError
|
|
14
7
|
class AuthenticationError < Error; end
|
|
15
|
-
|
|
8
|
+
ValidationError = MailboxKit::ValidationError
|
|
16
9
|
class RateLimitError < Error; end
|
|
17
10
|
class ServerError < Error; end
|
|
18
11
|
class NetworkError < Error; end
|