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
|
@@ -4,6 +4,16 @@ require "cloudflare/email/envelope"
|
|
|
4
4
|
module Cloudflare
|
|
5
5
|
module Email
|
|
6
6
|
module Mailboxes
|
|
7
|
+
# A routing snapshot, not a model or an authorization token. Use it inside
|
|
8
|
+
# the yielded tenant context; later jobs must resolve/check policy again.
|
|
9
|
+
Destination = Struct.new(:tenant_key, :mailbox_id, :address_id,
|
|
10
|
+
:receiving_domain_id, :recipient, :owner_ref, :catch_all, keyword_init: true) do
|
|
11
|
+
def initialize(**attributes)
|
|
12
|
+
super(**{ catch_all: false }.merge(attributes).transform_values { |value| value.is_a?(String) ? value.dup.freeze : value })
|
|
13
|
+
freeze
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
7
17
|
class << self
|
|
8
18
|
# Call after host authorization. The directory is a control-plane API,
|
|
9
19
|
# not a public endpoint accepting arbitrary customer domain claims.
|
|
@@ -27,16 +37,23 @@ module Cloudflare
|
|
|
27
37
|
|
|
28
38
|
# Only call after verifying the complete ingress HMAC. Resolve before
|
|
29
39
|
# ActionMailbox or ActiveStorage accesses a tenant connection.
|
|
30
|
-
def receive(recipient
|
|
31
|
-
raise ArgumentError, "persistence block required" unless
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
def receive(recipient:, &block)
|
|
41
|
+
raise ArgumentError, "persistence block required" unless block
|
|
42
|
+
in_recipient_tenant(recipient) do |session, address, directory|
|
|
43
|
+
session.receive(address, directory) do |destination|
|
|
44
|
+
# Preserve strict callbacks accepted before destinations were yielded.
|
|
45
|
+
block.lambda? && block.arity.zero? ? block.call : block.call(destination)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Lookup only: the host owns persistence and the block's return value.
|
|
51
|
+
# Verify ingress before calling this API. No ActionMailbox is required.
|
|
52
|
+
def with_recipient(recipient:)
|
|
53
|
+
raise ArgumentError, "a block is required" unless block_given?
|
|
54
|
+
in_recipient_tenant(recipient) do |session, address, directory|
|
|
55
|
+
session.with_recipient(address, directory) { |destination| yield destination }
|
|
37
56
|
end
|
|
38
|
-
rescue ::ActiveRecord::RecordNotFound
|
|
39
|
-
raise Unavailable, "mailbox address unavailable"
|
|
40
57
|
end
|
|
41
58
|
|
|
42
59
|
def canonical_address(value)
|
|
@@ -45,6 +62,17 @@ module Cloudflare
|
|
|
45
62
|
end
|
|
46
63
|
value.downcase
|
|
47
64
|
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def in_recipient_tenant(recipient)
|
|
69
|
+
address = canonical_address(recipient)
|
|
70
|
+
directory = ReceivingDomain.find_by(domain: address.split("@", 2).last, state: "active")
|
|
71
|
+
raise Unavailable, "receiving domain unavailable" unless directory
|
|
72
|
+
for_tenant(directory.tenant_key) do |session|
|
|
73
|
+
yield session, address, directory
|
|
74
|
+
end
|
|
75
|
+
end
|
|
48
76
|
end
|
|
49
77
|
|
|
50
78
|
class Session
|
|
@@ -100,6 +128,29 @@ module Cloudflare
|
|
|
100
128
|
activate_address!(address.id, evidence: "Cloudflare address rule provisioned for Worker #{worker_name}")
|
|
101
129
|
end
|
|
102
130
|
|
|
131
|
+
# This records the host's verified provider routing decision; it does
|
|
132
|
+
# not provision Cloudflare DNS/rules or authorize arbitrary From values.
|
|
133
|
+
def enable_catch_all(address_id, evidence:)
|
|
134
|
+
context!
|
|
135
|
+
catch_all_schema!
|
|
136
|
+
raise ArgumentError, "catch-all route verification evidence is required" if evidence.to_s.strip.empty?
|
|
137
|
+
Address.transaction do
|
|
138
|
+
address = Address.where(tenant_key: tenant_key, state: "active").find(address_id)
|
|
139
|
+
active_mailbox!(address.mailbox_id)
|
|
140
|
+
active_domain!(address)
|
|
141
|
+
address.update!(catch_all: true, catch_all_evidence: evidence)
|
|
142
|
+
address
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def disable_catch_all(address_id)
|
|
147
|
+
context!
|
|
148
|
+
catch_all_schema!
|
|
149
|
+
# Disabling is permitted even after suspension; retain the previous
|
|
150
|
+
# verification evidence for operator inspection.
|
|
151
|
+
Address.where(tenant_key: tenant_key).find(address_id).tap { |address| address.update!(catch_all: false) }
|
|
152
|
+
end
|
|
153
|
+
|
|
103
154
|
def suspend(mailbox_id)
|
|
104
155
|
mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "suspended") }
|
|
105
156
|
end
|
|
@@ -237,23 +288,56 @@ module Cloudflare
|
|
|
237
288
|
context!
|
|
238
289
|
ensure_storage_connection! if defined?(::ActionMailbox::InboundEmail)
|
|
239
290
|
Mailbox.transaction do
|
|
240
|
-
destination =
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
active_domain!(destination)
|
|
244
|
-
inbound = yield
|
|
245
|
-
Message.create_or_find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id,
|
|
291
|
+
destination = resolve_destination(address, directory)
|
|
292
|
+
inbound = yield destination
|
|
293
|
+
Message.create_or_find_by!(tenant_key: tenant_key, mailbox_id: destination.mailbox_id,
|
|
246
294
|
inbound_email_id: inbound.id) { |row| row.recipient = address } if inbound
|
|
247
295
|
inbound
|
|
248
296
|
end
|
|
249
297
|
end
|
|
250
298
|
|
|
299
|
+
def with_recipient(address, directory)
|
|
300
|
+
context!
|
|
301
|
+
yield resolve_destination(address, directory)
|
|
302
|
+
end
|
|
303
|
+
|
|
251
304
|
private
|
|
252
305
|
|
|
306
|
+
def resolve_destination(address, directory)
|
|
307
|
+
destination = Address.where(tenant_key: tenant_key, address: address,
|
|
308
|
+
receiving_domain_id: directory.id).first
|
|
309
|
+
fallback = destination.nil?
|
|
310
|
+
if fallback && catch_all_schema_available?
|
|
311
|
+
destination = Address.where(tenant_key: tenant_key, receiving_domain_id: directory.id,
|
|
312
|
+
catch_all: true, state: "active").first
|
|
313
|
+
end
|
|
314
|
+
# An exact pending/suspended address intentionally reserves its name.
|
|
315
|
+
# It must never fall through to another mailbox's catch-all.
|
|
316
|
+
raise Unavailable, "mailbox address unavailable" unless destination&.state == "active"
|
|
317
|
+
mailbox = active_mailbox!(destination.mailbox_id)
|
|
318
|
+
active_domain!(destination)
|
|
319
|
+
Destination.new(tenant_key: tenant_key, mailbox_id: mailbox.id,
|
|
320
|
+
address_id: destination.id, receiving_domain_id: directory.id,
|
|
321
|
+
recipient: address, owner_ref: mailbox.owner_ref, catch_all: fallback)
|
|
322
|
+
rescue ::ActiveRecord::RecordNotFound
|
|
323
|
+
raise Unavailable, "mailbox address unavailable"
|
|
324
|
+
end
|
|
325
|
+
|
|
253
326
|
def context!
|
|
254
327
|
raise ConfigurationError, "mailbox session used outside its tenant context" unless Tenancy.require_context! == tenant_key
|
|
255
328
|
end
|
|
256
329
|
|
|
330
|
+
def catch_all_schema_available?
|
|
331
|
+
Address.connection.column_exists?(Address.table_name, :catch_all) &&
|
|
332
|
+
Address.connection.column_exists?(Address.table_name, :catch_all_evidence)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def catch_all_schema!
|
|
336
|
+
unless catch_all_schema_available?
|
|
337
|
+
raise ConfigurationError, "run cloudflare:email:mailboxes:catch_all migrations for this tenant first"
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
|
|
257
341
|
def ensure_storage_connection!
|
|
258
342
|
unless defined?(::ActionMailbox::InboundEmail) && ::ActionMailbox::InboundEmail.connection_pool == Mailbox.connection_pool &&
|
|
259
343
|
::ActiveStorage::Blob.connection_pool == Mailbox.connection_pool
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module Management
|
|
4
|
+
# A host supplies authentication, mailbox ownership and domain entitlement.
|
|
5
|
+
# Defaults deliberately grant no access. No principal comes from params.
|
|
6
|
+
class Adapter
|
|
7
|
+
attr_reader :controller
|
|
8
|
+
|
|
9
|
+
def initialize(controller = nil)
|
|
10
|
+
@controller = controller
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authenticate! = false
|
|
14
|
+
def tenant_key = nil
|
|
15
|
+
def mailboxes(session) = session.mailboxes.none
|
|
16
|
+
def allowed?(action, mailbox = nil) = false
|
|
17
|
+
def domains(session) = []
|
|
18
|
+
|
|
19
|
+
# Override these two hooks when the host keeps a linked product model or
|
|
20
|
+
# uses a deployment policy to activate/provision verified addresses.
|
|
21
|
+
def create_mailbox(session, name:, address:)
|
|
22
|
+
session.create(name: name, address: address)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add_address(session, mailbox, address:)
|
|
26
|
+
session.add_address(mailbox.id, address: address)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module Management
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :adapter, :back_path
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def self.configuration
|
|
9
|
+
@configuration ||= Configuration.new
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.configure
|
|
13
|
+
yield configuration
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
require "action_controller/railtie"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Management
|
|
7
|
+
class Engine < ::Rails::Engine
|
|
8
|
+
isolate_namespace Cloudflare::Email::Management
|
|
9
|
+
config.root = File.expand_path("../../../..", __dir__)
|
|
10
|
+
|
|
11
|
+
config.paths["config/routes.rb"] = "lib/cloudflare/email/management/routes.rb"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
:root { color-scheme: light; --ink: #172c35; --muted: #536871; --line: #dce4e6; --accent: #12655c; --surface: #fff; }
|
|
2
|
+
* { box-sizing: border-box; }
|
|
3
|
+
body { margin: 0; background: #f5f7f7; color: var(--ink); font: 16px/1.55 system-ui, sans-serif; }
|
|
4
|
+
a { color: var(--accent); text-underline-offset: 3px; }
|
|
5
|
+
a:hover { text-decoration-thickness: 2px; }
|
|
6
|
+
:focus-visible { outline: 3px solid #b66a17; outline-offset: 4px; }
|
|
7
|
+
.shell { width: min(1160px, 100% - 48px); margin: 0 auto; }
|
|
8
|
+
.site-header { background: var(--surface); border-bottom: 1px solid var(--line); }
|
|
9
|
+
.header-content { display: flex; justify-content: space-between; align-items: center; gap: 16px; min-height: 76px; }
|
|
10
|
+
.brand { color: var(--ink); font-weight: 750; font-size: 18px; text-decoration: none; }
|
|
11
|
+
.header-caption, .muted, .field-help { color: var(--muted); }
|
|
12
|
+
main.shell { padding-top: 32px; padding-bottom: 64px; }
|
|
13
|
+
h1, h2, h3, p { margin: 0; }
|
|
14
|
+
h1 { font-size: clamp(26px, 4vw, 36px); line-height: 1.2; letter-spacing: -.035em; overflow-wrap: anywhere; }
|
|
15
|
+
h2 { font-size: 17px; line-height: 1.4; }
|
|
16
|
+
h3 { font-size: 17px; }
|
|
17
|
+
.small, .field-help { font-size: 13px; }
|
|
18
|
+
.eyebrow { color: var(--accent); font-size: 12px; font-weight: 750; text-transform: uppercase; letter-spacing: .1em; margin-bottom: 8px; }
|
|
19
|
+
.page-heading { display: flex; justify-content: space-between; align-items: center; gap: 24px; margin-bottom: 28px; }
|
|
20
|
+
.page-heading .muted { margin-top: 12px; max-width: 640px; }
|
|
21
|
+
.workspace-grid { display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(280px, 1fr); gap: 24px; align-items: start; }
|
|
22
|
+
.panel { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; overflow: hidden; min-width: 0; }
|
|
23
|
+
.panel-heading { padding: 20px 24px; border-bottom: 1px solid var(--line); }
|
|
24
|
+
.panel-body { padding: 24px; }
|
|
25
|
+
.stack { display: flex; flex-direction: column; gap: 20px; }
|
|
26
|
+
.field { display: flex; flex-direction: column; gap: 8px; }
|
|
27
|
+
label { font-weight: 650; font-size: 14px; }
|
|
28
|
+
input[type="text"], input[type="email"] { width: 100%; border: 1px solid #a6b7be; border-radius: 7px; padding: 11px 12px; background: #fff; color: var(--ink); font: inherit; }
|
|
29
|
+
.button { display: inline-flex; align-items: center; justify-content: center; min-height: 42px; padding: 9px 15px; border: 1px solid var(--accent); border-radius: 7px; background: var(--accent); color: #fff; font: inherit; font-size: 14px; font-weight: 650; text-decoration: none; cursor: pointer; text-align: center; }
|
|
30
|
+
.button:hover { filter: brightness(.93); }
|
|
31
|
+
.button-secondary { background: #fff; color: var(--ink); border-color: #a6b7be; }
|
|
32
|
+
.record-list { list-style: none; margin: 0; padding: 0; }
|
|
33
|
+
.record-row { padding: 20px 24px; display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
|
34
|
+
.record-row + .record-row { border-top: 1px solid var(--line); }
|
|
35
|
+
.record-content { min-width: 0; }
|
|
36
|
+
.record-title { font-weight: 650; overflow-wrap: anywhere; }
|
|
37
|
+
.record-content p { margin-top: 4px; }
|
|
38
|
+
.badge { display: inline-block; border: 1px solid var(--line); border-radius: 5px; background: #f3f6f6; color: #364f58; padding: 3px 8px; font-size: 12px; font-weight: 650; white-space: nowrap; }
|
|
39
|
+
.status-list { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
|
|
40
|
+
.address-row { align-items: flex-start; flex-direction: column; gap: 8px; }
|
|
41
|
+
.empty-state { padding: 48px 24px; text-align: center; }
|
|
42
|
+
.empty-state p { color: var(--muted); max-width: 340px; margin: 8px auto 0; }
|
|
43
|
+
.pagination { padding: 16px 24px; border-top: 1px solid var(--line); }
|
|
44
|
+
.actions { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
|
45
|
+
.actions form { margin: 0; }
|
|
46
|
+
.breadcrumbs { display: flex; flex-wrap: wrap; gap: 10px; color: var(--muted); font-size: 14px; margin-bottom: 28px; overflow-wrap: anywhere; }
|
|
47
|
+
.notice { border: 1px solid #a7ccc0; border-radius: 8px; background: #edf8f2; color: #205340; padding: 14px 18px; margin-bottom: 24px; overflow-wrap: anywhere; }
|
|
48
|
+
.notice-error { border-color: #e8b9b3; background: #fff2ef; color: #8d2a21; }
|
|
49
|
+
.message-metadata { display: grid; grid-template-columns: 84px minmax(0, 1fr); gap: 10px 20px; margin: 0; font-size: 14px; }
|
|
50
|
+
.message-metadata dt { color: var(--muted); }
|
|
51
|
+
.message-metadata dd { margin: 0; overflow-wrap: anywhere; }
|
|
52
|
+
.message-body { border-top: 1px solid var(--line); padding: 28px 24px; }
|
|
53
|
+
.message-body pre { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; margin: 0; }
|
|
54
|
+
.attachments { border-top: 1px solid var(--line); }
|
|
55
|
+
.attachments p { margin-top: 8px; }
|
|
56
|
+
.attachments li { overflow-wrap: anywhere; }
|
|
57
|
+
.skip-link { position: absolute; left: 16px; top: -100px; background: #fff; padding: 12px; z-index: 1; }
|
|
58
|
+
.skip-link:focus { top: 12px; }
|
|
59
|
+
@media (max-width: 760px) {
|
|
60
|
+
.shell { width: calc(100% - 32px); }
|
|
61
|
+
.header-content { min-height: 64px; }
|
|
62
|
+
.header-caption { display: none; }
|
|
63
|
+
.workspace-grid { grid-template-columns: minmax(0, 1fr); }
|
|
64
|
+
.page-heading { align-items: flex-start; flex-direction: column; }
|
|
65
|
+
.panel-heading, .panel-body, .record-row, .message-body { padding: 20px; }
|
|
66
|
+
.record-row { align-items: flex-start; }
|
|
67
|
+
.message-metadata { grid-template-columns: 64px minmax(0, 1fr); gap: 10px; }
|
|
68
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Cloudflare::Email::Management::Engine.routes.draw do
|
|
2
|
+
get "style.css", to: "styles#show", as: :style
|
|
3
|
+
root to: "mailboxes#index"
|
|
4
|
+
resources :mailboxes, only: [:index, :show, :create] do
|
|
5
|
+
member do
|
|
6
|
+
post :aliases, action: :add_address
|
|
7
|
+
post :suspend
|
|
8
|
+
post :resume
|
|
9
|
+
get "messages/:message_id", action: :message, as: :message
|
|
10
|
+
post :mark_read
|
|
11
|
+
post :archive
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Explicit opt-in: require this before Rails initializes, then configure the
|
|
2
|
+
# adapter in an initializer. Loading the core gem never mounts this interface.
|
|
3
|
+
require "rails/engine"
|
|
4
|
+
require "cloudflare-email"
|
|
5
|
+
require "cloudflare/email/management/adapter"
|
|
6
|
+
require "cloudflare/email/management/configuration"
|
|
7
|
+
require "cloudflare/email/management/engine"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "cloudflare/email/signing"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Signed assertions supplied by the forwarding integration, not proof that
|
|
7
|
+
# the original sender passed SPF/DKIM/DMARC. Never read these from MIME.
|
|
8
|
+
module ProviderMetadata
|
|
9
|
+
METADATA_KEY = "cloudflare_email_provider_metadata".freeze
|
|
10
|
+
MAX_ENCODED_BYTES = 16 * 1024
|
|
11
|
+
MAX_DEPTH = 8
|
|
12
|
+
SOURCE = /\A[a-z][a-z0-9_.-]{0,127}\z/
|
|
13
|
+
|
|
14
|
+
def self.valid?(value)
|
|
15
|
+
value.is_a?(Hash) && value.size == 2 && value.key?("data") && value.key?("source") &&
|
|
16
|
+
value["source"].is_a?(String) && SOURCE.match?(value["source"]) &&
|
|
17
|
+
value["data"].is_a?(Hash) && json_value?(value, 0)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.json_value?(value, depth)
|
|
21
|
+
case value
|
|
22
|
+
when Hash
|
|
23
|
+
depth < MAX_DEPTH && value.all? { |key, item| key.is_a?(String) && utf8?(key) && json_value?(item, depth + 1) }
|
|
24
|
+
when Array
|
|
25
|
+
depth < MAX_DEPTH && value.all? { |item| json_value?(item, depth + 1) }
|
|
26
|
+
when String then utf8?(value)
|
|
27
|
+
when Integer, TrueClass, FalseClass, NilClass then true
|
|
28
|
+
when Float then value.finite?
|
|
29
|
+
else false
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.utf8?(value)
|
|
34
|
+
value.encode(Encoding::UTF_8).valid_encoding?
|
|
35
|
+
rescue EncodingError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.encode(source:, data:)
|
|
40
|
+
value = {"source" => source, "data" => data}
|
|
41
|
+
raise ArgumentError, "invalid provider metadata" unless valid?(value)
|
|
42
|
+
encoded = Signing.base64url_encode(JSON.generate(value))
|
|
43
|
+
raise ArgumentError, "provider metadata exceeds size limit" if encoded.bytesize > MAX_ENCODED_BYTES
|
|
44
|
+
encoded
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.decode(encoded)
|
|
48
|
+
return unless encoded.is_a?(String) && encoded.bytesize.between?(1, MAX_ENCODED_BYTES)
|
|
49
|
+
return unless /\A[A-Za-z0-9_-]+\z/.match?(encoded)
|
|
50
|
+
bytes = Signing.base64url_decode(encoded)
|
|
51
|
+
return unless Signing.base64url_encode(bytes) == encoded
|
|
52
|
+
text = bytes.force_encoding(Encoding::UTF_8)
|
|
53
|
+
return unless text.valid_encoding?
|
|
54
|
+
value = JSON.parse(text, max_nesting: MAX_DEPTH)
|
|
55
|
+
value if valid?(value)
|
|
56
|
+
rescue JSON::ParserError, ArgumentError
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def self.for(inbound_email)
|
|
61
|
+
metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
|
|
62
|
+
return unless metadata.is_a?(Hash) && metadata["signature_version"] == 3
|
|
63
|
+
value = metadata.reject { |key, _| key == "signature_version" }
|
|
64
|
+
value if valid?(value)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Stable receipt identity if JSON object members arrive in another order.
|
|
68
|
+
def self.canonical(value)
|
|
69
|
+
case value
|
|
70
|
+
when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
|
|
71
|
+
when Array then value.map { |item| canonical(item) }
|
|
72
|
+
else value
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "timeout"
|
|
6
|
+
require "time"
|
|
7
|
+
require "digest"
|
|
8
|
+
require "cloudflare/email/message_id"
|
|
9
|
+
|
|
10
|
+
module Cloudflare
|
|
11
|
+
module Email
|
|
12
|
+
# Optional, authenticated, delivered-only evidence. This is not a webhook.
|
|
13
|
+
module RoutingAnalytics
|
|
14
|
+
class Error < StandardError; end
|
|
15
|
+
|
|
16
|
+
class Evidence
|
|
17
|
+
attr_reader :payload
|
|
18
|
+
|
|
19
|
+
def initialize(payload)
|
|
20
|
+
@payload = JSON.parse(JSON.generate(payload))
|
|
21
|
+
validate!
|
|
22
|
+
freeze_value(@payload)
|
|
23
|
+
freeze
|
|
24
|
+
rescue JSON::ParserError, JSON::GeneratorError, KeyError, ArgumentError, TypeError
|
|
25
|
+
raise Error, "Routing analytics evidence is malformed", cause: nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def event = payload.fetch("event")
|
|
29
|
+
def account_id = payload.fetch("account_id")
|
|
30
|
+
def zone_id = payload.fetch("zone_id")
|
|
31
|
+
def message_id = MessageId.normalize(event.fetch("messageId"))
|
|
32
|
+
def occurred_at = Time.iso8601(event.fetch("datetime"))
|
|
33
|
+
|
|
34
|
+
def identity
|
|
35
|
+
Digest::SHA256.hexdigest(JSON.generate([account_id, zone_id, event.fetch("sessionId"),
|
|
36
|
+
message_id, occurred_at.iso8601(9), event.fetch("status")]))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Query windows change between polls; the authentic row must not change.
|
|
40
|
+
def same_event?(other)
|
|
41
|
+
account_id == other.account_id && zone_id == other.zone_id && event == other.event
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def validate!
|
|
47
|
+
unless payload.is_a?(Hash) && payload["source"] == "cloudflare_routing_analytics" &&
|
|
48
|
+
payload["account_id"].is_a?(String) && !payload["account_id"].empty? &&
|
|
49
|
+
payload["zone_id"].is_a?(String) && payload["zone_id"].match?(/\A[a-f0-9]{32}\z/) &&
|
|
50
|
+
event.is_a?(Hash) && event["eventType"] == "newEmail" && event["status"] == "delivered" &&
|
|
51
|
+
event["isNDR"] == 0 && event["isLastEvent"] == 1 && event["sampleInterval"] == 1 &&
|
|
52
|
+
event["sessionId"].is_a?(String) && !event["sessionId"].strip.empty? &&
|
|
53
|
+
event["from"].is_a?(String) && !event["from"].strip.empty? &&
|
|
54
|
+
event["messageId"].is_a?(String) && !message_id.empty?
|
|
55
|
+
raise Error, "Routing analytics evidence is not an unsampled final delivery"
|
|
56
|
+
end
|
|
57
|
+
start = Time.iso8601(payload.fetch("query_started_at"))
|
|
58
|
+
finish = Time.iso8601(payload.fetch("query_finished_at"))
|
|
59
|
+
unless start <= occurred_at && occurred_at <= finish && finish <= Time.now.utc
|
|
60
|
+
raise Error, "Routing analytics evidence is outside its query window"
|
|
61
|
+
end
|
|
62
|
+
response = payload.fetch("response")
|
|
63
|
+
viewer = response["data"] if response.is_a?(Hash)
|
|
64
|
+
viewer = viewer["viewer"] if viewer.is_a?(Hash)
|
|
65
|
+
zones = viewer["zones"] if viewer.is_a?(Hash)
|
|
66
|
+
unless response.is_a?(Hash) && response["success"] != false &&
|
|
67
|
+
(response["errors"].nil? || response["errors"] == []) && zones.is_a?(Array) && zones.one? &&
|
|
68
|
+
zones.first.is_a?(Hash) && zones.first["zoneTag"] == zone_id
|
|
69
|
+
raise Error, "Routing analytics evidence response has unexpected zone"
|
|
70
|
+
end
|
|
71
|
+
rows = zones.first["emailRoutingAdaptive"]
|
|
72
|
+
unless rows.is_a?(Array) && rows.length < 100 && rows.all? { |row| row.is_a?(Hash) } && rows.include?(event)
|
|
73
|
+
raise Error, "Routing analytics evidence response is malformed or truncated"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def freeze_value(value)
|
|
78
|
+
value.each { |key, item| freeze_value(key); freeze_value(item) } if value.is_a?(Hash)
|
|
79
|
+
value.each { |item| freeze_value(item) } if value.is_a?(Array)
|
|
80
|
+
value.freeze
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class Client
|
|
85
|
+
REQUEST_TIMEOUT = 5
|
|
86
|
+
MAX_RESPONSE_BYTES = 1_000_000
|
|
87
|
+
QUERY = <<~GRAPHQL.freeze
|
|
88
|
+
query($zone: string!, $ids: [string!], $start: Time!, $finish: Time!) {
|
|
89
|
+
viewer { zones(filter: {zoneTag: $zone}) {
|
|
90
|
+
zoneTag
|
|
91
|
+
emailRoutingAdaptive(limit: 100, orderBy: [datetime_ASC], filter: {
|
|
92
|
+
messageId_in: $ids, datetime_geq: $start, datetime_leq: $finish
|
|
93
|
+
}) { messageId sessionId datetime eventType status isNDR isLastEvent sampleInterval from to action }
|
|
94
|
+
} }
|
|
95
|
+
}
|
|
96
|
+
GRAPHQL
|
|
97
|
+
|
|
98
|
+
def initialize(account_id:, zone_id:, api_token:)
|
|
99
|
+
unless account_id.is_a?(String) && !account_id.empty? && zone_id.is_a?(String) &&
|
|
100
|
+
zone_id.match?(/\A[a-f0-9]{32}\z/) && api_token.is_a?(String) && !api_token.strip.empty? &&
|
|
101
|
+
!api_token.match?(/[\r\n]/)
|
|
102
|
+
raise ArgumentError, "Routing analytics requires an account, zone ID and API token"
|
|
103
|
+
end
|
|
104
|
+
@account, @zone, @token = account_id.dup.freeze, zone_id.dup.freeze, api_token.dup.freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def inspect = "#<#{self.class} credentials=[FILTERED]>"
|
|
108
|
+
|
|
109
|
+
def verify_account!
|
|
110
|
+
@verified = false
|
|
111
|
+
result = request("/zones/#{@zone}")["result"]
|
|
112
|
+
unless result.is_a?(Hash) && result["id"] == @zone && result["account"].is_a?(Hash) &&
|
|
113
|
+
result["account"]["id"] == @account
|
|
114
|
+
raise Error, "Routing analytics zone/account mismatch"
|
|
115
|
+
end
|
|
116
|
+
@verified = true
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def delivery_evidence(message_id:, since:, until_time:)
|
|
120
|
+
raise Error, "Verify routing analytics zone/account first" unless @verified
|
|
121
|
+
id = MessageId.normalize(message_id)
|
|
122
|
+
unless !id.empty? && since.is_a?(Time) && until_time.is_a?(Time) && since <= until_time && until_time <= Time.now.utc
|
|
123
|
+
raise ArgumentError, "A message ID and valid historical Time window are required"
|
|
124
|
+
end
|
|
125
|
+
# Analytics timestamps have second precision, unlike saved attempts.
|
|
126
|
+
start, finish = Time.at(since.to_i).utc.iso8601, until_time.getutc.iso8601
|
|
127
|
+
data = request("/graphql", query: QUERY, variables: {
|
|
128
|
+
zone: @zone, ids: [id, "<#{id}>"], start: start, finish: finish
|
|
129
|
+
})
|
|
130
|
+
viewer = data["data"]
|
|
131
|
+
viewer = viewer["viewer"] if viewer.is_a?(Hash)
|
|
132
|
+
zones = viewer["zones"] if viewer.is_a?(Hash)
|
|
133
|
+
unless zones.is_a?(Array) && zones.one? && zones.first.is_a?(Hash) && zones.first["zoneTag"] == @zone
|
|
134
|
+
raise Error, "Routing analytics response has unexpected zone"
|
|
135
|
+
end
|
|
136
|
+
rows = zones.first["emailRoutingAdaptive"]
|
|
137
|
+
unless rows.is_a?(Array) && rows.length < 100 && rows.all? { |row| row.is_a?(Hash) }
|
|
138
|
+
raise Error, "Routing analytics response is malformed or truncated"
|
|
139
|
+
end
|
|
140
|
+
final_rows = rows.select { |row| row["eventType"] == "newEmail" && row["isNDR"] == 0 &&
|
|
141
|
+
row["isLastEvent"] == 1 && row["sampleInterval"] == 1 }
|
|
142
|
+
final_rows.group_by { |row| [MessageId.normalize(row["messageId"]), row["sessionId"]] }.each_value do |events|
|
|
143
|
+
if events.uniq.length > 1
|
|
144
|
+
raise Error, "Routing analytics response contains contradictory final events"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
rows.filter_map do |row|
|
|
148
|
+
next unless row["eventType"] == "newEmail" && row["status"] == "delivered" &&
|
|
149
|
+
row["isNDR"] == 0 && row["isLastEvent"] == 1 && row["sampleInterval"] == 1
|
|
150
|
+
evidence = Evidence.new("source" => "cloudflare_routing_analytics", "account_id" => @account,
|
|
151
|
+
"zone_id" => @zone, "query_started_at" => start, "query_finished_at" => finish,
|
|
152
|
+
"response" => data, "event" => row)
|
|
153
|
+
raise Error, "Routing analytics response has unexpected Message-ID" unless evidence.message_id == id
|
|
154
|
+
evidence
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def request(path, body = nil)
|
|
161
|
+
uri = URI("https://api.cloudflare.com/client/v4#{path}")
|
|
162
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
163
|
+
http.use_ssl = true
|
|
164
|
+
http.open_timeout = http.read_timeout = http.write_timeout = REQUEST_TIMEOUT
|
|
165
|
+
http.max_retries = 0
|
|
166
|
+
request = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
|
|
167
|
+
request["Authorization"] = "Bearer #{@token}"
|
|
168
|
+
request["Content-Type"] = "application/json"
|
|
169
|
+
request.body = JSON.generate(body) if body
|
|
170
|
+
payload = +""
|
|
171
|
+
Timeout.timeout(REQUEST_TIMEOUT) do
|
|
172
|
+
http.request(request) do |response|
|
|
173
|
+
raise Error, "Routing analytics HTTP request failed" unless response.is_a?(Net::HTTPSuccess)
|
|
174
|
+
response.read_body do |chunk|
|
|
175
|
+
raise Error, "Routing analytics response is too large" if payload.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
|
|
176
|
+
payload << chunk
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
data = JSON.parse(payload)
|
|
181
|
+
unless data.is_a?(Hash) && data["success"] != false && (data["errors"].nil? || data["errors"] == [])
|
|
182
|
+
raise Error, "Routing analytics API rejected query"
|
|
183
|
+
end
|
|
184
|
+
data
|
|
185
|
+
rescue JSON::ParserError, KeyError, TypeError
|
|
186
|
+
raise Error, "Routing analytics API response is invalid", cause: nil
|
|
187
|
+
rescue Timeout::Error
|
|
188
|
+
raise Error, "Routing analytics HTTP deadline exceeded", cause: nil
|
|
189
|
+
rescue IOError, SystemCallError, SocketError, OpenSSL::SSL::SSLError,
|
|
190
|
+
Net::HTTPBadResponse, Net::HTTPHeaderSyntaxError, Net::ProtocolError
|
|
191
|
+
raise Error, "Routing analytics HTTP request failed", cause: nil
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|