cloudflare-email 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +149 -1
- data/README.md +287 -385
- data/SECURITY.md +51 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
- 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 +181 -0
- data/docs/architecture.md +105 -0
- data/docs/custom-ingress.md +207 -0
- data/docs/delivery-events.md +186 -0
- data/docs/features.md +138 -0
- data/docs/getting-started.md +308 -0
- data/docs/mailboxes.md +363 -0
- data/docs/management-engine.md +190 -0
- data/docs/outbox.md +175 -0
- data/docs/routing-deliveries.md +94 -0
- data/docs/routing-diagnostics.md +68 -0
- data/docs/tenant-mailboxes-plan.md +149 -0
- data/docs/thread-correlation.md +27 -0
- data/docs/troubleshooting.md +104 -0
- data/docs/upgrading-0.2.md +101 -0
- data/docs/upgrading-0.3.md +55 -0
- data/docs/verification/2026-09-10-followup.md +49 -0
- data/docs/verification/2026-09-10-install-upgrade.md +86 -0
- data/docs/verification/2026-09-10-live.md +102 -0
- data/docs/verification/2026-09-10.md +175 -0
- data/docs/verification/2026-09-11-custom-ingress.md +49 -0
- data/docs/verification/2026-09-11-extraction.md +32 -0
- data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
- data/docs/verification/2026-09-11-security.md +76 -0
- data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -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/examples/plain_ruby.rb +24 -0
- data/lib/cloudflare/email/active_record/base.rb +60 -0
- data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
- data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
- data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
- data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
- data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
- data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
- data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
- data/lib/cloudflare/email/active_record/outbox.rb +219 -0
- data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
- data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
- data/lib/cloudflare/email/active_record.rb +5 -0
- data/lib/cloudflare/email/check_route_task.rb +21 -0
- data/lib/cloudflare/email/client.rb +93 -18
- data/lib/cloudflare/email/consume_events_task.rb +22 -0
- data/lib/cloudflare/email/delivery_event.rb +70 -0
- data/lib/cloudflare/email/delivery_method.rb +5 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
- data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
- data/lib/cloudflare/email/dev_tunnel.rb +31 -5
- data/lib/cloudflare/email/doctor.rb +16 -38
- data/lib/cloudflare/email/endpoint.rb +24 -0
- data/lib/cloudflare/email/engine.rb +33 -0
- data/lib/cloudflare/email/envelope.rb +58 -0
- data/lib/cloudflare/email/event_consumer.rb +95 -0
- data/lib/cloudflare/email/ingress.rb +115 -0
- data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
- data/lib/cloudflare/email/mailboxes/events.rb +112 -0
- data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
- data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
- data/lib/cloudflare/email/mailboxes/models.rb +165 -0
- data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
- data/lib/cloudflare/email/mailboxes/service.rb +372 -0
- data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
- data/lib/cloudflare/email/mailboxes.rb +11 -0
- 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/message_id.rb +12 -0
- data/lib/cloudflare/email/provider_metadata.rb +77 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
- data/lib/cloudflare/email/provision_route_task.rb +2 -1
- data/lib/cloudflare/email/replay_events_job.rb +18 -0
- data/lib/cloudflare/email/response.rb +21 -3
- data/lib/cloudflare/email/routing_analytics.rb +196 -0
- data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
- data/lib/cloudflare/email/routing_provisioner.rb +71 -45
- data/lib/cloudflare/email/send_job.rb +41 -0
- data/lib/cloudflare/email/send_test.rb +5 -29
- data/lib/cloudflare/email/signing.rb +2 -2
- data/lib/cloudflare/email/tenancy.rb +78 -0
- data/lib/cloudflare/email/tenant_job_context.rb +90 -0
- data/lib/cloudflare/email/verification.rb +38 -8
- data/lib/cloudflare/email/version.rb +1 -1
- data/lib/cloudflare/email/worker_deployer.rb +41 -8
- data/lib/cloudflare-email.rb +4 -1
- data/lib/generators/cloudflare/email/install_generator.rb +53 -37
- 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/mailboxes_generator.rb +55 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
- data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
- data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
- data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -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/generators/cloudflare/email/templates/initializer.rb +3 -3
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
- data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
- data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
- data/lib/tasks/cloudflare_email.rake +42 -4
- 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 +231 -16
- data/templates/worker/docs/durable-inbound.md +149 -0
- data/templates/worker/package-lock.json +2627 -0
- data/templates/worker/package.json +8 -5
- data/templates/worker/scripts/wrangler.mjs +16 -0
- data/templates/worker/src/index.js +397 -25
- data/templates/worker/test/durable.test.ts +193 -0
- data/templates/worker/test/index.test.ts +183 -13
- data/templates/worker/test/relay.test.ts +226 -0
- data/templates/worker/wrangler.toml +63 -4
- metadata +154 -7
- data/lib/cloudflare/email/secure_message_id.rb +0 -89
data/SECURITY.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
The hardening described here is included in 0.2.0. Version 0.1.0 does not include
|
|
4
|
+
these fixes. Upgrade Rails and the bundled Worker together using the
|
|
5
|
+
[upgrade guide](docs/upgrading-0.2.md).
|
|
6
|
+
|
|
7
|
+
Rails integration is tested on patched Rails 7.2, 8.0, and 8.1; application
|
|
8
|
+
owners must update their own Rails, database adapter, and other dependencies.
|
|
9
|
+
The gem does not add Rails or SQLite as runtime dependencies.
|
|
10
|
+
|
|
11
|
+
Report sensitive findings privately to the maintainer at cole@dscribeai.com.
|
|
12
|
+
Include affected versions and impact; omit real credentials and private mail.
|
|
13
|
+
Do not place secrets or private message data in public issues.
|
|
14
|
+
|
|
15
|
+
## Deployment responsibilities
|
|
16
|
+
|
|
17
|
+
- Keep API and management tokens least-privileged and separate. Store tokens and
|
|
18
|
+
a strong ingress secret in a secret manager or Rails encrypted credentials.
|
|
19
|
+
Rotate compromised secrets on Rails and the Worker together. HTTPS endpoint
|
|
20
|
+
overrides are trusted operator configuration; never derive them from mail.
|
|
21
|
+
- Set request-size limits, timeouts and rate limits at the proxy/server. Rails
|
|
22
|
+
and the Worker bound MIME reads to 25 MiB by default (`MAX_EMAIL_BYTES`), but
|
|
23
|
+
cannot prevent upstream buffering or slow clients. Limits apply to raw MIME,
|
|
24
|
+
not just decoded attachments.
|
|
25
|
+
- Treat mail and attachments as untrusted content. The gem authenticates Worker
|
|
26
|
+
transport and SMTP envelope metadata, not a human sender's identity. Rendering,
|
|
27
|
+
sanitization, malware scanning and application authorization belong to the app.
|
|
28
|
+
- Protect raw MIME, attachments, outbox snapshots and event receipts with access
|
|
29
|
+
control, storage encryption, backups and retention policies. Avoid logging raw
|
|
30
|
+
responses/errors: provider details and application logs can contain private mail.
|
|
31
|
+
- Restrict queue producers and consumers. Event account/domain checks are
|
|
32
|
+
validation, not cryptographic signatures. Monitor unacknowledged malformed
|
|
33
|
+
messages, processing failures and unmatched receipts.
|
|
34
|
+
- Database administrators and direct SQL writes are trusted. ActiveRecord
|
|
35
|
+
read-only evidence fields do not provide tamper-proof storage. Keep tenant
|
|
36
|
+
authorization around outbox operations and receipt access in the application.
|
|
37
|
+
- The optional mailbox session scopes tenant and mailbox access, but does not
|
|
38
|
+
authenticate users. Keep domain registration/activation administrative, verify
|
|
39
|
+
ownership externally, and use your access resolver to choose tenant keys.
|
|
40
|
+
Ingress selects a registered tenant only after full signature verification.
|
|
41
|
+
Raw ActiveRecord access remains privileged, especially in shared-database mode.
|
|
42
|
+
Private tenant storage, consistent migrations and trusted job payloads are
|
|
43
|
+
required. Drain old framework jobs before enabling database tenancy; jobs
|
|
44
|
+
without the new tenant metadata are rejected rather than guessing a tenant.
|
|
45
|
+
- Development tunnels expose the ingress to the internet. Use the bundled task,
|
|
46
|
+
dedicated development mail routes and test data; stop tunnels when finished.
|
|
47
|
+
The Host guard restricts routing but is not a sandbox for the development app
|
|
48
|
+
or a replacement for safe error pages and request logging.
|
|
49
|
+
|
|
50
|
+
See [the September 11 security review](docs/verification/2026-09-11-security.md)
|
|
51
|
+
for findings, verification, and remaining limits.
|
|
@@ -1,41 +1,52 @@
|
|
|
1
|
-
require "cloudflare/email/
|
|
1
|
+
require "cloudflare/email/ingress"
|
|
2
2
|
|
|
3
3
|
module Cloudflare
|
|
4
4
|
module Email
|
|
5
5
|
# ActionMailbox ingress for Cloudflare Email Worker forwards.
|
|
6
6
|
#
|
|
7
|
-
# The
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# X-CF-Email-Signature: <hex digest>
|
|
7
|
+
# The default Worker uses v2 authenticated SMTP envelopes; custom Workers
|
|
8
|
+
# can opt into v3 to authenticate additional provider metadata. Both use
|
|
9
|
+
# the shared, bounded Ingress verifier before any tenant lookup or storage.
|
|
11
10
|
#
|
|
12
11
|
# Set the shared secret in Rails credentials under cloudflare.ingress_secret
|
|
13
12
|
# (or in the CLOUDFLARE_INGRESS_SECRET env var) and as the Worker secret
|
|
14
13
|
# INGRESS_SECRET via `wrangler secret put INGRESS_SECRET`.
|
|
15
14
|
class IngressController < ActionMailbox::BaseController
|
|
15
|
+
DEFAULT_MAX_EMAIL_BYTES = 25 * 1024 * 1024
|
|
16
16
|
param_encoding :create, "raw_email", Encoding::ASCII_8BIT
|
|
17
17
|
|
|
18
18
|
def create
|
|
19
19
|
ActiveSupport::Notifications.instrument(
|
|
20
20
|
"cloudflare_email.ingress",
|
|
21
|
-
bytes:
|
|
21
|
+
bytes: 0,
|
|
22
22
|
) do |payload|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
23
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
24
|
+
result = Cloudflare::Email::Ingress.verify(secret: secret,
|
|
25
|
+
headers: request.headers, body: request.body,
|
|
26
|
+
content_length: request.content_length, max_email_bytes: max_email_bytes)
|
|
27
|
+
payload[:bytes] = result.bytes.to_i
|
|
28
|
+
payload[:result] = result.status
|
|
29
|
+
case result.status
|
|
29
30
|
when :stale
|
|
30
|
-
payload[:result] = :stale
|
|
31
31
|
head :request_timeout
|
|
32
32
|
when :bad_signature
|
|
33
|
-
payload[:result] = :bad_signature
|
|
34
33
|
head :unauthorized
|
|
34
|
+
when :too_large
|
|
35
|
+
head :payload_too_large
|
|
35
36
|
when :ok
|
|
36
|
-
inbound =
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
inbound = if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?
|
|
38
|
+
recipient = result.message.envelope.fetch("to")
|
|
39
|
+
begin
|
|
40
|
+
Cloudflare::Email::Mailboxes.receive(recipient: recipient) { result.message.persist_action_mailbox! }
|
|
41
|
+
rescue Cloudflare::Email::Mailboxes::Unavailable
|
|
42
|
+
payload[:result] = :unavailable_mailbox
|
|
43
|
+
next head(:unprocessable_entity)
|
|
44
|
+
end
|
|
45
|
+
else
|
|
46
|
+
result.message.persist_action_mailbox!
|
|
47
|
+
end
|
|
48
|
+
payload[:result] = inbound ? :ok : :duplicate
|
|
49
|
+
payload[:message_id] = inbound&.message_id
|
|
39
50
|
head :ok
|
|
40
51
|
end
|
|
41
52
|
end
|
|
@@ -49,10 +60,11 @@ module Cloudflare
|
|
|
49
60
|
:cloudflare
|
|
50
61
|
end
|
|
51
62
|
|
|
52
|
-
def
|
|
53
|
-
@
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
def max_email_bytes
|
|
64
|
+
@max_email_bytes ||= begin
|
|
65
|
+
value = Integer(ENV.fetch("MAX_EMAIL_BYTES", DEFAULT_MAX_EMAIL_BYTES.to_s), 10)
|
|
66
|
+
raise ArgumentError, "MAX_EMAIL_BYTES must be positive" unless value.positive?
|
|
67
|
+
value
|
|
56
68
|
end
|
|
57
69
|
end
|
|
58
70
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module Management
|
|
4
|
+
class MailboxesController < ActionController::Base
|
|
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
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module Management
|
|
4
|
+
class StylesController < ActionController::Base
|
|
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
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<div class="page-heading">
|
|
2
|
+
<div>
|
|
3
|
+
<p class="eyebrow">Email workspace</p>
|
|
4
|
+
<h1>Mailboxes</h1>
|
|
5
|
+
<p class="muted">Manage accepted addresses and the messages delivered to them.</p>
|
|
6
|
+
</div>
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
<div class="workspace-grid">
|
|
10
|
+
<section class="panel" aria-labelledby="mailboxes-heading">
|
|
11
|
+
<div class="panel-heading"><h2 id="mailboxes-heading">Your mailboxes</h2></div>
|
|
12
|
+
<% if @mailboxes.empty? %>
|
|
13
|
+
<div class="empty-state">
|
|
14
|
+
<h3>No mailboxes to show</h3>
|
|
15
|
+
<p>Create a mailbox to give incoming email a home.</p>
|
|
16
|
+
</div>
|
|
17
|
+
<% else %>
|
|
18
|
+
<ul class="record-list">
|
|
19
|
+
<% @mailboxes.each do |mailbox| %>
|
|
20
|
+
<li class="record-row">
|
|
21
|
+
<div class="record-content">
|
|
22
|
+
<% if allowed?(:show, mailbox) %>
|
|
23
|
+
<%= link_to mailbox.name, mailbox_path(mailbox.id), class: "record-title" %>
|
|
24
|
+
<% else %>
|
|
25
|
+
<span class="record-title"><%= mailbox.name %></span>
|
|
26
|
+
<% end %>
|
|
27
|
+
<p class="muted small">Mailbox #<%= mailbox.id %></p>
|
|
28
|
+
</div>
|
|
29
|
+
<span class="badge"><%= mailbox.state.capitalize %></span>
|
|
30
|
+
</li>
|
|
31
|
+
<% end %>
|
|
32
|
+
</ul>
|
|
33
|
+
<% end %>
|
|
34
|
+
<% if @next_cursor.present? %>
|
|
35
|
+
<nav class="pagination" aria-label="Mailbox pages">
|
|
36
|
+
<%= link_to "Next mailboxes →", root_path(after: @next_cursor), class: "button button-secondary" %>
|
|
37
|
+
</nav>
|
|
38
|
+
<% end %>
|
|
39
|
+
</section>
|
|
40
|
+
|
|
41
|
+
<% if allowed?(:create) %>
|
|
42
|
+
<section class="panel" aria-labelledby="create-heading">
|
|
43
|
+
<div class="panel-heading"><h2 id="create-heading">Create a mailbox</h2></div>
|
|
44
|
+
<div class="panel-body">
|
|
45
|
+
<% if @domains.empty? %>
|
|
46
|
+
<p class="muted">An administrator needs to register an active receiving domain before you can create a mailbox.</p>
|
|
47
|
+
<% else %>
|
|
48
|
+
<%= form_with url: mailboxes_path, scope: :mailbox, local: true, class: "stack" do |form| %>
|
|
49
|
+
<div class="field">
|
|
50
|
+
<%= form.label :name, "Mailbox name" %>
|
|
51
|
+
<%= form.text_field :name, required: true, maxlength: 255, placeholder: "Customer support" %>
|
|
52
|
+
</div>
|
|
53
|
+
<div class="field">
|
|
54
|
+
<%= form.label :address, "Email address" %>
|
|
55
|
+
<%= form.email_field :address, required: true, maxlength: 254, placeholder: "support@#{@domains.first}", aria: { describedby: "create-domains" } %>
|
|
56
|
+
<p id="create-domains" class="field-help">Available domains: <%= @domains.join(", ") %></p>
|
|
57
|
+
</div>
|
|
58
|
+
<p class="field-help">New addresses start pending. Your administrator must confirm their routing before they can receive mail.</p>
|
|
59
|
+
<%= form.submit "Create mailbox", class: "button" %>
|
|
60
|
+
<% end %>
|
|
61
|
+
<% end %>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
|
2
|
+
<%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span>
|
|
3
|
+
<%= link_to @mailbox.name, mailbox_path(@mailbox.id) %> <span aria-hidden="true">/</span>
|
|
4
|
+
<span>Message</span>
|
|
5
|
+
</nav>
|
|
6
|
+
<div class="page-heading">
|
|
7
|
+
<div><p class="eyebrow">Received message</p><h1><%= @subject.presence || "(No subject)" %></h1></div>
|
|
8
|
+
<div class="actions">
|
|
9
|
+
<% if allowed?(:mark_read, @mailbox) %>
|
|
10
|
+
<%= button_to(@entry.read_at ? "Mark unread" : "Mark read", mark_read_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, read: @entry.read_at ? "false" : "true" }, class: "button button-secondary") %>
|
|
11
|
+
<% end %>
|
|
12
|
+
<% if allowed?(:archive, @mailbox) %>
|
|
13
|
+
<%= button_to(@entry.archived_at ? "Unarchive" : "Archive", archive_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, archived: @entry.archived_at ? "false" : "true" }, class: "button button-secondary") %>
|
|
14
|
+
<% end %>
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<article class="panel" aria-label="Email message">
|
|
18
|
+
<div class="panel-body">
|
|
19
|
+
<dl class="message-metadata">
|
|
20
|
+
<dt>From</dt><dd><%= @from.presence || "(Unknown sender)" %></dd>
|
|
21
|
+
<dt>To</dt><dd><%= @entry.recipient %></dd>
|
|
22
|
+
<dt>Received</dt><dd><time datetime="<%= @entry.created_at.iso8601 %>"><%= @entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></dd>
|
|
23
|
+
<dt>Status</dt><dd><%= @entry.read_at ? "Read" : "Unread" %><%= " · Archived" if @entry.archived_at %></dd>
|
|
24
|
+
</dl>
|
|
25
|
+
</div>
|
|
26
|
+
<div class="message-body"><pre><%= @body.presence || "No text content is available for this message." %></pre></div>
|
|
27
|
+
<% if @attachments.any? %>
|
|
28
|
+
<section class="panel-body attachments" aria-labelledby="attachments-heading">
|
|
29
|
+
<h2 id="attachments-heading">Attachments</h2>
|
|
30
|
+
<p class="field-help">File names are shown for reference. Downloads are not available in this dashboard.</p>
|
|
31
|
+
<ul><% @attachments.each do |filename| %><li><%= filename.presence || "Unnamed attachment" %></li><% end %></ul>
|
|
32
|
+
</section>
|
|
33
|
+
<% end %>
|
|
34
|
+
</article>
|
|
@@ -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>
|