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
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "cloudflare/email/envelope"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
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
|
+
|
|
17
|
+
class << self
|
|
18
|
+
# Call after host authorization. The directory is a control-plane API,
|
|
19
|
+
# not a public endpoint accepting arbitrary customer domain claims.
|
|
20
|
+
def register_domain(domain:, tenant_key:, account_id:)
|
|
21
|
+
Tenancy.normalize_key(tenant_key)
|
|
22
|
+
ReceivingDomain.create!(domain: domain, tenant_key: tenant_key, account_id: account_id)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def activate_domain!(id, evidence:, sending_enabled: false)
|
|
26
|
+
raise ArgumentError, "verification evidence is required" if evidence.to_s.strip.empty?
|
|
27
|
+
domain = ReceivingDomain.find(id)
|
|
28
|
+
domain.update!(state: "active", provisioning_evidence: evidence,
|
|
29
|
+
verified_at: Time.now.utc, sending_enabled: sending_enabled)
|
|
30
|
+
domain
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def for_tenant(key)
|
|
34
|
+
raise ArgumentError, "a block is required" unless block_given?
|
|
35
|
+
Tenancy.with(key) { yield Session.new(key) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Only call after verifying the complete ingress HMAC. Resolve before
|
|
39
|
+
# ActionMailbox or ActiveStorage accesses a tenant connection.
|
|
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 }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def canonical_address(value)
|
|
60
|
+
unless Envelope.valid_address?(value)
|
|
61
|
+
raise ValidationError, "mailbox address must be an ASCII dot-atom address"
|
|
62
|
+
end
|
|
63
|
+
value.downcase
|
|
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
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
class Session
|
|
79
|
+
attr_reader :tenant_key
|
|
80
|
+
|
|
81
|
+
def initialize(tenant_key)
|
|
82
|
+
@tenant_key = tenant_key
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def mailboxes
|
|
86
|
+
context!
|
|
87
|
+
Mailbox.where(tenant_key: tenant_key)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def create(name:, address:, owner_ref: nil)
|
|
91
|
+
context!
|
|
92
|
+
Mailbox.transaction do
|
|
93
|
+
mailbox = Mailbox.create!(tenant_key: tenant_key, name: name, owner_ref: owner_ref)
|
|
94
|
+
add_address(mailbox.id, address: address)
|
|
95
|
+
mailbox
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def add_address(mailbox_id, address:)
|
|
100
|
+
mailbox = active_mailbox!(mailbox_id)
|
|
101
|
+
canonical = Mailboxes.canonical_address(address)
|
|
102
|
+
local, domain = canonical.split("@", 2)
|
|
103
|
+
directory = ReceivingDomain.find_by!(domain: domain, tenant_key: tenant_key, state: "active")
|
|
104
|
+
Address.create!(tenant_key: tenant_key, mailbox_id: mailbox.id,
|
|
105
|
+
receiving_domain_id: directory.id, local_part: local, domain: domain,
|
|
106
|
+
address: canonical, state: "pending")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def activate_address!(address_id, evidence:)
|
|
110
|
+
context!
|
|
111
|
+
raise ArgumentError, "route verification evidence is required" if evidence.to_s.strip.empty?
|
|
112
|
+
address = Address.where(tenant_key: tenant_key).find(address_id)
|
|
113
|
+
active_mailbox!(address.mailbox_id)
|
|
114
|
+
active_domain!(address)
|
|
115
|
+
address.update!(state: "active", provisioning_evidence: evidence)
|
|
116
|
+
address
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def provision_address!(address_id, provisioner:, worker_name:)
|
|
120
|
+
context!
|
|
121
|
+
raise ArgumentError, "provision outside a database transaction" if Address.connection.transaction_open?
|
|
122
|
+
address = Address.where(tenant_key: tenant_key).find(address_id)
|
|
123
|
+
active_mailbox!(address.mailbox_id)
|
|
124
|
+
active_domain!(address)
|
|
125
|
+
provisioner.provision(address: address.address, worker_name: worker_name)
|
|
126
|
+
# Failed requests leave the address pending; the existing provisioner
|
|
127
|
+
# upserts a rule, making a deliberate retry safe.
|
|
128
|
+
activate_address!(address.id, evidence: "Cloudflare address rule provisioned for Worker #{worker_name}")
|
|
129
|
+
end
|
|
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
|
+
|
|
154
|
+
def suspend(mailbox_id)
|
|
155
|
+
mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "suspended") }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def resume(mailbox_id)
|
|
159
|
+
mailboxes.find(mailbox_id).tap { |mailbox| mailbox.update!(state: "active") }
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def messages(mailbox_id)
|
|
163
|
+
mailbox = mailboxes.find(mailbox_id)
|
|
164
|
+
Message.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def addresses(mailbox_id)
|
|
168
|
+
mailbox = mailboxes.find(mailbox_id)
|
|
169
|
+
Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def suspend_address(mailbox_id, address_id)
|
|
173
|
+
addresses(mailbox_id).find(address_id).tap { |address| address.update!(state: "suspended") }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def outbound_messages(mailbox_id)
|
|
177
|
+
mailbox = mailboxes.find(mailbox_id)
|
|
178
|
+
OutboundMessage.where(tenant_key: tenant_key, mailbox_id: mailbox.id)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def inbound_email(mailbox_id, message_id)
|
|
182
|
+
message = messages(mailbox_id).find(message_id)
|
|
183
|
+
ensure_storage_connection!
|
|
184
|
+
::ActionMailbox::InboundEmail.find(message.inbound_email_id)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Explicit permanent removal. Archive is the reversible default.
|
|
188
|
+
# Other mailbox memberships retain their shared raw source.
|
|
189
|
+
def purge_message(mailbox_id, message_id)
|
|
190
|
+
context!
|
|
191
|
+
ensure_storage_connection!
|
|
192
|
+
Message.transaction do
|
|
193
|
+
message = messages(mailbox_id).find(message_id)
|
|
194
|
+
inbound_id = message.inbound_email_id
|
|
195
|
+
message.destroy!
|
|
196
|
+
unless Message.where(inbound_email_id: inbound_id).exists?
|
|
197
|
+
::ActionMailbox::InboundEmail.find_by(id: inbound_id)&.destroy!
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def mark_read(mailbox_id, message_id, read: true)
|
|
203
|
+
messages(mailbox_id).find(message_id).update!(read_at: read ? Time.now.utc : nil)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def archive(mailbox_id, message_id, archived: true)
|
|
207
|
+
messages(mailbox_id).find(message_id).update!(archived_at: archived ? Time.now.utc : nil)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def prepare(mailbox_id, operation_key:, mail:)
|
|
211
|
+
context!
|
|
212
|
+
raise ArgumentError, "operation_key is required" unless operation_key.is_a?(String) && !operation_key.strip.empty?
|
|
213
|
+
mail = mail.message if mail.respond_to?(:message) && !mail.respond_to?(:encoded)
|
|
214
|
+
raise ValidationError, "mail must be a rendered Mail message" unless mail.respond_to?(:encoded)
|
|
215
|
+
mailbox = active_mailbox!(mailbox_id)
|
|
216
|
+
from = Array(mail.from)
|
|
217
|
+
raise ValidationError, "mail must have one mailbox From address" unless from.length == 1
|
|
218
|
+
sender = Array(mail.sender)
|
|
219
|
+
address = authorized_sender!(mailbox, from.first)
|
|
220
|
+
envelope_from = mail.respond_to?(:smtp_envelope_from) ? mail.smtp_envelope_from : from.first
|
|
221
|
+
unless Mailboxes.canonical_address(envelope_from) == address.address &&
|
|
222
|
+
sender.all? { |value| Mailboxes.canonical_address(value) == address.address }
|
|
223
|
+
raise ValidationError, "MIME and envelope sender must match the mailbox address"
|
|
224
|
+
end
|
|
225
|
+
domain = active_domain!(address)
|
|
226
|
+
raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled
|
|
227
|
+
key = "mailbox:#{Digest::SHA256.hexdigest(JSON.generate([tenant_key, mailbox.id, operation_key]))}"
|
|
228
|
+
Mailbox.transaction do
|
|
229
|
+
delivery = ActiveRecord::Outbox.prepare_mail(account_id: domain.account_id, operation_key: key, mail: mail)
|
|
230
|
+
OutboundMessage.create_or_find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id,
|
|
231
|
+
outbound_delivery_id: delivery.id)
|
|
232
|
+
delivery
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Enqueue only after the outer transaction commits. No mail or model
|
|
237
|
+
# GlobalIDs are serialized into this job, just stable identities.
|
|
238
|
+
def enqueue(mailbox_id, operation_key:)
|
|
239
|
+
context!
|
|
240
|
+
raise ArgumentError, "enqueue after database commit" if Mailbox.connection.transaction_open?
|
|
241
|
+
linked_delivery!(mailbox_id, operation_key)
|
|
242
|
+
SendJob.perform_later(tenant_key, mailbox_id, operation_key)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def deliver(mailbox_id, operation_key:)
|
|
246
|
+
mailbox = active_mailbox!(mailbox_id)
|
|
247
|
+
delivery = linked_delivery!(mailbox.id, operation_key)
|
|
248
|
+
address = authorized_sender!(mailbox, delivery.from_address)
|
|
249
|
+
domain = active_domain!(address)
|
|
250
|
+
raise Unavailable, "domain is not enabled for sending" unless domain.sending_enabled && domain.account_id == delivery.account_id
|
|
251
|
+
ActiveRecord::Outbox.deliver(delivery, client: Mailboxes.client_for(tenant_key, delivery.account_id))
|
|
252
|
+
handler = Mailboxes.delivery_handler
|
|
253
|
+
delivery.with_lock { handler.call(delivery) } if handler.respond_to?(:call)
|
|
254
|
+
Events.register(delivery, tenant_key: tenant_key) if %w[accepted partial].include?(delivery.state) && delivery.provider_message_id
|
|
255
|
+
delivery
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def reconcile(mailbox_id, operation_key:, **evidence, &handler)
|
|
259
|
+
delivery = linked_delivery!(mailbox_id, operation_key)
|
|
260
|
+
raise ArgumentError, "reconcile outside an outer transaction" if Mailbox.connection.transaction_open?
|
|
261
|
+
ActiveRecord::Outbox.reconcile(delivery, **evidence, &handler)
|
|
262
|
+
Events.register(delivery, tenant_key: tenant_key) if %w[accepted partial].include?(delivery.state) && delivery.provider_message_id
|
|
263
|
+
delivery
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# A bounded repair pass after an enqueue gap or cross-database commit
|
|
267
|
+
# gap. Does not automatically resend sending/unknown/rejected rows.
|
|
268
|
+
def recover(limit: 100, after_id: 0)
|
|
269
|
+
context!
|
|
270
|
+
raise ArgumentError, "recover outside a database transaction" if Mailbox.connection.transaction_open?
|
|
271
|
+
raise ArgumentError, "limit must be 1..1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
|
|
272
|
+
raise ArgumentError, "after_id must be nonnegative" unless after_id.is_a?(Integer) && after_id >= 0
|
|
273
|
+
links = OutboundMessage.where(tenant_key: tenant_key).where("id > ?", after_id).order(:id).limit(limit).to_a
|
|
274
|
+
links.each do |link|
|
|
275
|
+
delivery = ActiveRecord::OutboundDelivery.find(link.outbound_delivery_id)
|
|
276
|
+
if delivery.state == "prepared"
|
|
277
|
+
enqueue(link.mailbox_id, operation_key: delivery.operation_key) if mailboxes.find(link.mailbox_id).state == "active"
|
|
278
|
+
elsif %w[accepted partial].include?(delivery.state)
|
|
279
|
+
handler = Mailboxes.delivery_handler
|
|
280
|
+
delivery.with_lock { handler.call(delivery) } if handler.respond_to?(:call)
|
|
281
|
+
Events.register(delivery, tenant_key: tenant_key) if delivery.provider_message_id
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
links.last&.id
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def receive(address, directory)
|
|
288
|
+
context!
|
|
289
|
+
ensure_storage_connection! if defined?(::ActionMailbox::InboundEmail)
|
|
290
|
+
Mailbox.transaction do
|
|
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,
|
|
294
|
+
inbound_email_id: inbound.id) { |row| row.recipient = address } if inbound
|
|
295
|
+
inbound
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def with_recipient(address, directory)
|
|
300
|
+
context!
|
|
301
|
+
yield resolve_destination(address, directory)
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
private
|
|
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
|
+
|
|
326
|
+
def context!
|
|
327
|
+
raise ConfigurationError, "mailbox session used outside its tenant context" unless Tenancy.require_context! == tenant_key
|
|
328
|
+
end
|
|
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
|
+
|
|
341
|
+
def ensure_storage_connection!
|
|
342
|
+
unless defined?(::ActionMailbox::InboundEmail) && ::ActionMailbox::InboundEmail.connection_pool == Mailbox.connection_pool &&
|
|
343
|
+
::ActiveStorage::Blob.connection_pool == Mailbox.connection_pool
|
|
344
|
+
raise ConfigurationError, "ActionMailbox, ActiveStorage and mailbox records must share the tenant connection"
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def active_mailbox!(id)
|
|
349
|
+
context!
|
|
350
|
+
mailboxes.where(state: "active").find(id)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def active_domain!(address)
|
|
354
|
+
ReceivingDomain.find_by!(id: address.receiving_domain_id, domain: address.domain,
|
|
355
|
+
tenant_key: tenant_key, state: "active")
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def authorized_sender!(mailbox, address)
|
|
359
|
+
Address.where(tenant_key: tenant_key, mailbox_id: mailbox.id, state: "active")
|
|
360
|
+
.find_by!(address: Mailboxes.canonical_address(address))
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def linked_delivery!(mailbox_id, operation_key)
|
|
364
|
+
mailbox = mailboxes.find(mailbox_id)
|
|
365
|
+
delivery = ActiveRecord::OutboundDelivery.find_by!(operation_key: operation_key)
|
|
366
|
+
OutboundMessage.find_by!(tenant_key: tenant_key, mailbox_id: mailbox.id, outbound_delivery_id: delivery.id)
|
|
367
|
+
delivery
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module Mailboxes
|
|
4
|
+
class SharedEventReceipt < Mailboxes.directory_base
|
|
5
|
+
self.table_name = "cloudflare_email_shared_event_receipts"
|
|
6
|
+
attr_readonly :account_id, :event_id, :message_id, :recipient, :payload_json
|
|
7
|
+
|
|
8
|
+
def event
|
|
9
|
+
DeliveryEvent.new(JSON.parse(payload_json))
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Explicit opt-in. Configure Tenancy and Mailboxes before loading this file.
|
|
2
|
+
require "cloudflare-email"
|
|
3
|
+
require "cloudflare/email/tenancy"
|
|
4
|
+
require "cloudflare/email/mailboxes/configuration"
|
|
5
|
+
require "cloudflare/email/active_record"
|
|
6
|
+
require "cloudflare/email/mailboxes/models"
|
|
7
|
+
require "cloudflare/email/mailboxes/events"
|
|
8
|
+
require "cloudflare/email/mailboxes/service"
|
|
9
|
+
require "cloudflare/email/mailboxes/jobs"
|
|
10
|
+
|
|
11
|
+
Cloudflare::Email::Mailboxes.enable!
|
|
@@ -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,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# Correlation only: a Message-ID does not authorize access to a thread.
|
|
6
|
+
module MessageId
|
|
7
|
+
def self.normalize(value)
|
|
8
|
+
value.to_s.strip.sub(/\A<([^<>]+)>\z/, '\1')
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -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
|