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,58 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "cloudflare/email/signing"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Authenticated SMTP routing metadata, kept separate from sender-supplied MIME.
|
|
7
|
+
# v2 encodes exact {"from": string, "to": string} JSON as unpadded base64url.
|
|
8
|
+
module Envelope
|
|
9
|
+
METADATA_KEY = "cloudflare_email_envelope".freeze
|
|
10
|
+
MAX_ENCODED_BYTES = 1024
|
|
11
|
+
LOCAL_PART = /\A[A-Za-z0-9.!#$%&'*+\/=\?^_`{|}~-]+\z/
|
|
12
|
+
DOMAIN_LABEL = /\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\z/
|
|
13
|
+
|
|
14
|
+
def self.valid_address?(address, allow_empty: false)
|
|
15
|
+
return false unless address.is_a?(String) && address.ascii_only?
|
|
16
|
+
return true if allow_empty && address.empty?
|
|
17
|
+
return false if address.bytesize > 254
|
|
18
|
+
parts = address.split("@", -1)
|
|
19
|
+
return false unless parts.size == 2
|
|
20
|
+
local, domain = parts
|
|
21
|
+
local.bytesize <= 64 && LOCAL_PART.match?(local) &&
|
|
22
|
+
!local.start_with?(".") && !local.end_with?(".") && !local.include?("..") &&
|
|
23
|
+
domain.split(".", -1).all? { |label| DOMAIN_LABEL.match?(label) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.valid?(value)
|
|
27
|
+
value.is_a?(Hash) && value.size == 2 && value.key?("from") && value.key?("to") &&
|
|
28
|
+
valid_address?(value["from"], allow_empty: true) && valid_address?(value["to"])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.encode(from:, to:)
|
|
32
|
+
value = { "from" => from, "to" => to }
|
|
33
|
+
raise ArgumentError, "invalid SMTP envelope" unless valid?(value)
|
|
34
|
+
Signing.base64url_encode(JSON.generate(value))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.decode(encoded)
|
|
38
|
+
return nil unless encoded.is_a?(String) && encoded.bytesize.between?(1, MAX_ENCODED_BYTES)
|
|
39
|
+
return nil unless /\A[A-Za-z0-9_-]+\z/.match?(encoded)
|
|
40
|
+
decoded = Signing.base64url_decode(encoded)
|
|
41
|
+
return nil unless Signing.base64url_encode(decoded) == encoded
|
|
42
|
+
value = JSON.parse(decoded)
|
|
43
|
+
valid?(value) ? value : nil
|
|
44
|
+
rescue JSON::ParserError, ArgumentError
|
|
45
|
+
nil
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Only metadata written after v2/v3 verification is trusted. MIME headers
|
|
49
|
+
# (including X-CF-* headers) never participate in this lookup.
|
|
50
|
+
def self.for(inbound_email)
|
|
51
|
+
metadata = inbound_email.raw_email.blob&.metadata&.fetch(METADATA_KEY, nil)
|
|
52
|
+
return nil unless metadata.is_a?(Hash) && [2, 3].include?(metadata["version"])
|
|
53
|
+
value = metadata.reject { |key, _| key == "version" }
|
|
54
|
+
valid?(value) ? value : nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
require "base64"
|
|
2
|
+
require "cloudflare/email/client"
|
|
3
|
+
require "cloudflare/email/delivery_event"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
# Short-poll a dedicated Email Sending event queue from Ruby/Rails.
|
|
8
|
+
# Each message is acknowledged only after the caller's block succeeds.
|
|
9
|
+
# Handler/parse/ack failures propagate; unacknowledged leases expire and
|
|
10
|
+
# are redelivered according to the queue's retry/dead-letter policy.
|
|
11
|
+
class EventConsumer < Client
|
|
12
|
+
# Up to 100 queue messages, including encoded payloads and metadata.
|
|
13
|
+
DEFAULT_MAX_RESPONSE_BYTES = 32 * 1_048_576
|
|
14
|
+
|
|
15
|
+
def initialize(queue_id:, domains: nil, max_response_bytes: DEFAULT_MAX_RESPONSE_BYTES, **options)
|
|
16
|
+
raise ConfigurationError, "queue_id is required" unless queue_id.to_s.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
17
|
+
super(**options.merge(retries: 0, retry_ambiguous: false, max_response_bytes: max_response_bytes))
|
|
18
|
+
@queue_id = queue_id
|
|
19
|
+
@domains = domains && Array(domains).map(&:downcase)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def poll(batch_size: 5, visibility_timeout_ms: 300_000)
|
|
23
|
+
raise ArgumentError, "a delivery event handler block is required" unless block_given?
|
|
24
|
+
unless batch_size.is_a?(Integer) && (1..100).cover?(batch_size)
|
|
25
|
+
raise ArgumentError, "batch_size must be between 1 and 100"
|
|
26
|
+
end
|
|
27
|
+
unless visibility_timeout_ms.is_a?(Integer) && (1_000..43_200_000).cover?(visibility_timeout_ms)
|
|
28
|
+
raise ArgumentError, "visibility_timeout_ms must be between 1000 and 43200000"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
response = request(:post, queue_path("pull"), {
|
|
32
|
+
batch_size: batch_size, visibility_timeout_ms: visibility_timeout_ms,
|
|
33
|
+
})
|
|
34
|
+
messages = response.result["messages"]
|
|
35
|
+
raise Error, "invalid queue pull response: messages must be an array" unless messages.is_a?(Array)
|
|
36
|
+
|
|
37
|
+
messages.each do |message|
|
|
38
|
+
unless message.is_a?(Hash) && message["lease_id"].is_a?(String) && !message["lease_id"].empty?
|
|
39
|
+
raise ValidationError, "queue message is missing its lease_id"
|
|
40
|
+
end
|
|
41
|
+
event = decode_event(message)
|
|
42
|
+
unless event.account_id == account_id && (!@domains || @domains.include?(event.domain.to_s.downcase))
|
|
43
|
+
raise ValidationError, "Email Sending event does not match configured account/domain"
|
|
44
|
+
end
|
|
45
|
+
instrument("cloudflare_email.delivery_event", event_id: event.event_id,
|
|
46
|
+
message_id: event.message_id, status: event.status) do
|
|
47
|
+
yield event
|
|
48
|
+
end
|
|
49
|
+
ack = request(:post, queue_path("ack"), { acks: [{ lease_id: message["lease_id"] }], retries: [] })
|
|
50
|
+
unless ack.result["ackCount"] == 1
|
|
51
|
+
raise Error.new("queue did not acknowledge the message; it may be redelivered", response: ack.raw, status: ack.status)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
messages.length
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def response_byte_limit(uri)
|
|
60
|
+
# ACKs contain counts, never a queue batch. Keep their smaller budget
|
|
61
|
+
# even when a pull needs room for 100 encoded event payloads.
|
|
62
|
+
uri.path.end_with?("/messages/ack") ? [@max_response_bytes, Client::DEFAULT_MAX_RESPONSE_BYTES].min : @max_response_bytes
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def queue_path(action)
|
|
66
|
+
"/accounts/#{account_id}/queues/#{@queue_id}/messages/#{action}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def decode_event(message)
|
|
70
|
+
body = message["body"]
|
|
71
|
+
metadata = message["metadata"]
|
|
72
|
+
content_type = metadata.is_a?(Hash) ? metadata["CF-Content-Type"] : nil
|
|
73
|
+
case content_type
|
|
74
|
+
when "json"
|
|
75
|
+
# Email Sending subscriptions return plain JSON strings from HTTP
|
|
76
|
+
# pull. Also accept the base64 JSON transport used by other producers.
|
|
77
|
+
begin
|
|
78
|
+
body = JSON.parse(body)
|
|
79
|
+
rescue JSON::ParserError
|
|
80
|
+
body = JSON.parse(Base64.strict_decode64(body))
|
|
81
|
+
end
|
|
82
|
+
when "bytes"
|
|
83
|
+
body = JSON.parse(Base64.strict_decode64(body))
|
|
84
|
+
when "text"
|
|
85
|
+
body = JSON.parse(body)
|
|
86
|
+
else
|
|
87
|
+
raise ValidationError, "unsupported queue content type; use json, bytes, or text"
|
|
88
|
+
end
|
|
89
|
+
DeliveryEvent.new(body)
|
|
90
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
91
|
+
raise ValidationError, "queue body must contain a valid JSON Email Sending event", cause: nil
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "cloudflare/email/verification"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# Shared bounded verification for custom Rack/Rails ingestion endpoints.
|
|
7
|
+
# This API deliberately performs no domain lookup or business processing.
|
|
8
|
+
module Ingress
|
|
9
|
+
DEFAULT_MAX_EMAIL_BYTES = 25 * 1024 * 1024
|
|
10
|
+
Result = Struct.new(:status, :message, :bytes, keyword_init: true)
|
|
11
|
+
|
|
12
|
+
class VerifiedMessage
|
|
13
|
+
attr_reader :body, :envelope, :provider_metadata, :storage_metadata, :message_checksum
|
|
14
|
+
|
|
15
|
+
def initialize(body:, envelope:, provider_metadata:, version:)
|
|
16
|
+
@body = body.b.freeze
|
|
17
|
+
@envelope = Ingress.deep_freeze(envelope)
|
|
18
|
+
@provider_metadata = Ingress.deep_freeze(provider_metadata)
|
|
19
|
+
@storage_metadata = {Envelope::METADATA_KEY => envelope.merge("version" => version.to_i)}
|
|
20
|
+
if provider_metadata
|
|
21
|
+
@storage_metadata[ProviderMetadata::METADATA_KEY] = provider_metadata.merge("signature_version" => 3)
|
|
22
|
+
end
|
|
23
|
+
Ingress.deep_freeze(@storage_metadata)
|
|
24
|
+
identity = if version == "3"
|
|
25
|
+
"v3\0#{JSON.generate([envelope.fetch('from'), envelope.fetch('to'), ProviderMetadata.canonical(provider_metadata)])}\0".b
|
|
26
|
+
else
|
|
27
|
+
"v2\0#{envelope.fetch('to')}\0".b
|
|
28
|
+
end
|
|
29
|
+
@message_checksum = Digest::SHA256.hexdigest(identity + @body).freeze
|
|
30
|
+
freeze
|
|
31
|
+
end
|
|
32
|
+
private_class_method :new
|
|
33
|
+
|
|
34
|
+
# Call within Mailboxes.receive when using the optional registry so the
|
|
35
|
+
# framework records and memberships share the resolved tenant context.
|
|
36
|
+
# Custom endpoints may instead persist body/metadata in their own store.
|
|
37
|
+
def persist_action_mailbox!
|
|
38
|
+
::ActionMailbox::InboundEmail.transaction do
|
|
39
|
+
inbound = ::ActionMailbox::InboundEmail.create_and_extract_message_id!(body,
|
|
40
|
+
message_checksum: message_checksum, message_id: stable_message_id)
|
|
41
|
+
if inbound
|
|
42
|
+
blob = inbound.raw_email.blob
|
|
43
|
+
blob.update!(metadata: blob.metadata.merge(storage_metadata))
|
|
44
|
+
end
|
|
45
|
+
inbound
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# ActionMailbox's fallback includes Socket.gethostname. A replay on a
|
|
52
|
+
# different Rails host must use the same database identity, while the
|
|
53
|
+
# original MIME stays untouched. Preserve parseable provider IDs.
|
|
54
|
+
def stable_message_id
|
|
55
|
+
parsed = begin
|
|
56
|
+
::Mail.from_source(body).message_id
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
parsed || "#{message_checksum}@cloudflare-email.invalid"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.verify(secret:, headers:, body:, content_length: nil,
|
|
65
|
+
max_email_bytes: DEFAULT_MAX_EMAIL_BYTES, now: Time.now.to_i, window: Verification::DEFAULT_WINDOW)
|
|
66
|
+
unless max_email_bytes.is_a?(Integer) && max_email_bytes.positive?
|
|
67
|
+
raise ArgumentError, "max_email_bytes must be a positive integer"
|
|
68
|
+
end
|
|
69
|
+
values = %w[Timestamp Signature Signature-Version Envelope Metadata].to_h do |name|
|
|
70
|
+
key = "X-CF-Email-#{name}"
|
|
71
|
+
value = headers[key] || headers["HTTP_#{key.upcase.tr('-', '_')}"]
|
|
72
|
+
[name, value.is_a?(String) ? value.dup.freeze : value]
|
|
73
|
+
end
|
|
74
|
+
options = {secret: secret, timestamp: values["Timestamp"], signature: values["Signature"],
|
|
75
|
+
version: values["Signature-Version"], envelope: values["Envelope"], metadata: values["Metadata"],
|
|
76
|
+
now: now, window: window}
|
|
77
|
+
status = Verification.verify_headers(**options)
|
|
78
|
+
return Result.new(status: status).freeze unless status == :ok
|
|
79
|
+
if content_length
|
|
80
|
+
length = Integer(content_length.to_s, exception: false)
|
|
81
|
+
return Result.new(status: :bad_signature).freeze unless length && length >= 0
|
|
82
|
+
return Result.new(status: :too_large).freeze if length > max_email_bytes
|
|
83
|
+
end
|
|
84
|
+
raw = if body.is_a?(String)
|
|
85
|
+
return Result.new(status: :too_large).freeze if body.bytesize > max_email_bytes
|
|
86
|
+
body.dup.b
|
|
87
|
+
elsif body.respond_to?(:read)
|
|
88
|
+
buffer = +"".b
|
|
89
|
+
while buffer.bytesize <= max_email_bytes
|
|
90
|
+
chunk = body.read(max_email_bytes + 1 - buffer.bytesize)
|
|
91
|
+
break if chunk.nil? || chunk.empty?
|
|
92
|
+
buffer << chunk.b
|
|
93
|
+
end
|
|
94
|
+
buffer
|
|
95
|
+
else
|
|
96
|
+
raise ArgumentError, "body must be a String or readable IO"
|
|
97
|
+
end
|
|
98
|
+
return Result.new(status: :too_large).freeze if raw.bytesize > max_email_bytes
|
|
99
|
+
status = Verification.verify(body: raw, **options)
|
|
100
|
+
return Result.new(status: status, bytes: raw.bytesize).freeze unless status == :ok
|
|
101
|
+
message = VerifiedMessage.send(:new, body: raw, version: values["Signature-Version"],
|
|
102
|
+
envelope: Envelope.decode(values["Envelope"]), provider_metadata: ProviderMetadata.decode(values["Metadata"]))
|
|
103
|
+
Result.new(status: :ok, message: message, bytes: raw.bytesize).freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.deep_freeze(value)
|
|
107
|
+
case value
|
|
108
|
+
when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
|
|
109
|
+
when Array then value.each { |item| deep_freeze(item) }
|
|
110
|
+
end
|
|
111
|
+
value.freeze
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "cloudflare/email/error"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Mailboxes
|
|
7
|
+
class Unavailable < Cloudflare::Email::Error; end
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def configure(directory_base: ::ActiveRecord::Base, client_resolver: nil)
|
|
11
|
+
if const_defined?(:ReceivingDomain, false)
|
|
12
|
+
raise ConfigurationError, "configure mailboxes before loading mailbox models"
|
|
13
|
+
end
|
|
14
|
+
unless directory_base.is_a?(Class) && directory_base <= ::ActiveRecord::Base
|
|
15
|
+
raise ConfigurationError, "directory_base must be an ActiveRecord base class"
|
|
16
|
+
end
|
|
17
|
+
if client_resolver && !client_resolver.respond_to?(:call)
|
|
18
|
+
raise ConfigurationError, "client_resolver must be callable"
|
|
19
|
+
end
|
|
20
|
+
@directory_base = directory_base
|
|
21
|
+
@client_resolver = client_resolver
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def directory_base = @directory_base || ::ActiveRecord::Base
|
|
25
|
+
|
|
26
|
+
def delivery_handler = rails_setting(:outbox_delivery_handler)
|
|
27
|
+
def recipient_handler = rails_setting(:outbox_recipient_handler)
|
|
28
|
+
|
|
29
|
+
def client_for(tenant_key, account_id)
|
|
30
|
+
client = if @client_resolver
|
|
31
|
+
@client_resolver.call(tenant_key, account_id)
|
|
32
|
+
else
|
|
33
|
+
require "cloudflare/email/credentials"
|
|
34
|
+
unless Credentials.account_id == account_id
|
|
35
|
+
raise ConfigurationError, "configure a tenant/account client_resolver for this sending account"
|
|
36
|
+
end
|
|
37
|
+
Client.new(account_id: account_id, api_token: Credentials.api_token, retry_ambiguous: false)
|
|
38
|
+
end
|
|
39
|
+
unless client.account_id == account_id && client.retry_ambiguous == false
|
|
40
|
+
raise ConfigurationError, "mailbox client must match account and disable ambiguous retries"
|
|
41
|
+
end
|
|
42
|
+
client
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enabled? = @enabled == true
|
|
46
|
+
def enable! = @enabled = true
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def rails_setting(name)
|
|
51
|
+
if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
|
|
52
|
+
::Rails.application.config.x.cloudflare_email.public_send(name)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
require "cloudflare/email/active_record/delivery_events"
|
|
2
|
+
require "cloudflare/email/mailboxes/shared_event_receipt"
|
|
3
|
+
require "cloudflare/email/mailboxes/provider_correlation"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
module Mailboxes
|
|
8
|
+
# Queue ACK follows shared intake. Projection is separately replayable:
|
|
9
|
+
# a crash between tenant commit and shared completion simply replays the
|
|
10
|
+
# tenant's idempotent receipt. No distributed transaction is assumed.
|
|
11
|
+
class Events
|
|
12
|
+
class << self
|
|
13
|
+
def record(event)
|
|
14
|
+
outside_shared_transaction!
|
|
15
|
+
event = DeliveryEvent.new(event.raw)
|
|
16
|
+
receipt = SharedEventReceipt.create_or_find_by!(account_id: event.account_id, event_id: event.event_id) do |row|
|
|
17
|
+
row.message_id = MessageId.normalize(event.message_id)
|
|
18
|
+
row.recipient = normalize_recipient(event.recipient)
|
|
19
|
+
row.payload_json = JSON.generate(event.raw)
|
|
20
|
+
row.state = "pending"
|
|
21
|
+
end
|
|
22
|
+
raise ValidationError, "event ID already recorded with a different payload" unless receipt.event.raw == event.raw
|
|
23
|
+
receipt
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Call after provider acceptance has committed in the tenant database.
|
|
27
|
+
# Duplicate registrations are harmless; collisions remain visible and
|
|
28
|
+
# deliberately prevent routing rather than selecting an arbitrary tenant.
|
|
29
|
+
def register(delivery, tenant_key: Tenancy.require_context!)
|
|
30
|
+
assert_context!(tenant_key)
|
|
31
|
+
outside_shared_transaction!
|
|
32
|
+
raise ArgumentError, "registration must follow the tenant commit" if delivery.class.connection.transaction_open?
|
|
33
|
+
delivery.reload
|
|
34
|
+
unless delivery.persisted? && %w[accepted partial].include?(delivery.state) && !delivery.provider_message_id.to_s.empty?
|
|
35
|
+
raise ArgumentError, "expected a committed accepted delivery with a provider message ID"
|
|
36
|
+
end
|
|
37
|
+
unless OutboundMessage.where(tenant_key: tenant_key, outbound_delivery_id: delivery.id).exists?
|
|
38
|
+
raise ArgumentError, "delivery does not belong to a mailbox in the current tenant"
|
|
39
|
+
end
|
|
40
|
+
delivery.outbound_recipients.map do |recipient|
|
|
41
|
+
ProviderCorrelation.create_or_find_by!(account_id: delivery.account_id,
|
|
42
|
+
message_id: MessageId.normalize(delivery.provider_message_id), recipient: normalize_recipient(recipient.recipient),
|
|
43
|
+
tenant_key: tenant_key.to_s, outbound_delivery_id: delivery.id)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def apply(receipt, &on_change)
|
|
48
|
+
outside_shared_transaction!
|
|
49
|
+
raise ArgumentError, "expected a persisted shared receipt" unless receipt.is_a?(SharedEventReceipt) && receipt.persisted?
|
|
50
|
+
receipt.reload
|
|
51
|
+
return receipt if receipt.state == "applied"
|
|
52
|
+
|
|
53
|
+
candidates = ProviderCorrelation.where(account_id: receipt.account_id,
|
|
54
|
+
message_id: receipt.message_id, recipient: receipt.recipient).limit(2).to_a
|
|
55
|
+
outcome = :unmatched
|
|
56
|
+
if candidates.length == 1
|
|
57
|
+
correlation = candidates.first
|
|
58
|
+
outcome = Tenancy.with(correlation.tenant_key) do
|
|
59
|
+
project(receipt.event, correlation, &on_change)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
# Never overwrite another worker's completed projection.
|
|
63
|
+
SharedEventReceipt.where(id: receipt.id, state: %w[pending unmatched]).update_all(
|
|
64
|
+
state: outcome.to_s, applied_at: outcome == :applied ? Time.now.utc : nil, updated_at: Time.now.utc)
|
|
65
|
+
receipt.reload
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A bounded page, not an unbounded find_each scan. Use the last scanned
|
|
69
|
+
# ID as after_id for the next page, and restart at zero on the next pass.
|
|
70
|
+
def replay(limit: 100, after_id: 0, account_id: nil, &on_change)
|
|
71
|
+
raise ArgumentError, "limit must be between 1 and 1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
|
|
72
|
+
raise ArgumentError, "after_id must be nonnegative" unless after_id.is_a?(Integer) && after_id >= 0
|
|
73
|
+
scope = SharedEventReceipt.where(state: %w[pending unmatched]).where("id > ?", after_id)
|
|
74
|
+
scope = scope.where(account_id: account_id) if account_id
|
|
75
|
+
scope.order(:id).limit(limit).to_a.each { |receipt| apply(receipt, &on_change) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def outside_shared_transaction!
|
|
81
|
+
if SharedEventReceipt.connection.transaction_open?
|
|
82
|
+
raise ArgumentError, "shared event processing must run outside an existing database transaction"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def normalize_recipient(value)
|
|
87
|
+
ActiveRecord::Outbox.normalize_recipient(value)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def assert_context!(key)
|
|
91
|
+
raise ConfigurationError, "delivery belongs to another tenant" unless Tenancy.require_context! == key
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def project(event, correlation, &on_change)
|
|
95
|
+
assert_context!(correlation.tenant_key)
|
|
96
|
+
return :unmatched unless OutboundMessage.where(tenant_key: correlation.tenant_key,
|
|
97
|
+
outbound_delivery_id: correlation.outbound_delivery_id).exists?
|
|
98
|
+
delivery = ActiveRecord::OutboundDelivery.find_by(id: correlation.outbound_delivery_id,
|
|
99
|
+
account_id: event.account_id, provider_message_id: MessageId.normalize(event.message_id), state: %w[accepted partial])
|
|
100
|
+
return :unmatched unless delivery && delivery.outbound_recipients.exists?(recipient: normalize_recipient(event.recipient))
|
|
101
|
+
return :unmatched unless ReceivingDomain.active.where(tenant_key: correlation.tenant_key,
|
|
102
|
+
account_id: event.account_id, domain: delivery.from_address.rpartition("@").last.downcase).exists?
|
|
103
|
+
# DeliveryEvents also refuses collisions within the tenant. Its
|
|
104
|
+
# transactional receipt protects callback writes against redelivery.
|
|
105
|
+
receipt = ActiveRecord::DeliveryEvents.record(event, &on_change)
|
|
106
|
+
receipt.state == "applied" ? :applied : :unmatched
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module Mailboxes
|
|
4
|
+
# ActionMailbox normally deletes processed mail after its retention window.
|
|
5
|
+
# A mailbox membership owns the raw source until explicit application purge.
|
|
6
|
+
module InboundRetention
|
|
7
|
+
def incinerate
|
|
8
|
+
return if Mailboxes.enabled? && Message.where(inbound_email_id: id).exists?
|
|
9
|
+
super
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
require "active_job"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
module Mailboxes
|
|
6
|
+
class SendJob < ::ActiveJob::Base
|
|
7
|
+
queue_as :mailers
|
|
8
|
+
|
|
9
|
+
def perform(tenant_key, mailbox_id, operation_key)
|
|
10
|
+
Mailboxes.for_tenant(tenant_key) { |session| session.deliver(mailbox_id, operation_key: operation_key) }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class RecoverJob < ::ActiveJob::Base
|
|
15
|
+
queue_as :mailers
|
|
16
|
+
|
|
17
|
+
def perform(tenant_key, after_id = 0)
|
|
18
|
+
cursor = Mailboxes.for_tenant(tenant_key) { |session| session.recover(after_id: after_id) }
|
|
19
|
+
self.class.perform_later(tenant_key, cursor) if cursor
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
class ReplayEventsJob < ::ActiveJob::Base
|
|
24
|
+
queue_as :mailers
|
|
25
|
+
|
|
26
|
+
def perform(after_id = 0)
|
|
27
|
+
handler = Mailboxes.recipient_handler
|
|
28
|
+
page = Events.replay(after_id: after_id, &(handler.method(:call) if handler.respond_to?(:call)))
|
|
29
|
+
self.class.perform_later(page.last.id) if page.length == 100
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
require "active_record"
|
|
2
|
+
require "cloudflare/email/active_record/base"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Mailboxes
|
|
7
|
+
# Domains are an administrator-maintained receiving directory. An active
|
|
8
|
+
# record is an assertion by the application, not DNS ownership verification.
|
|
9
|
+
class ReceivingDomain < Mailboxes.directory_base
|
|
10
|
+
self.table_name = "cloudflare_email_receiving_domains"
|
|
11
|
+
STATES = %w[pending active suspended].freeze
|
|
12
|
+
attr_readonly :domain, :tenant_key, :account_id
|
|
13
|
+
before_validation(on: :create) { self.domain = domain.to_s.strip.downcase }
|
|
14
|
+
validates :tenant_key, :account_id, presence: true
|
|
15
|
+
validates :domain, presence: true, uniqueness: true,
|
|
16
|
+
length: { maximum: 253 },
|
|
17
|
+
format: { with: /\A[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+\z/ }
|
|
18
|
+
validates :state, inclusion: { in: STATES }
|
|
19
|
+
validate do
|
|
20
|
+
errors.add(:domain, "has an oversized label") if domain.to_s.split(".").any? { |label| label.length > 63 }
|
|
21
|
+
end
|
|
22
|
+
scope :active, -> { where(state: "active") }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module TenantIdentity
|
|
26
|
+
extend ActiveSupport::Concern
|
|
27
|
+
included do
|
|
28
|
+
attr_readonly :tenant_key
|
|
29
|
+
validates :tenant_key, presence: true
|
|
30
|
+
before_validation :assign_tenant_identity
|
|
31
|
+
validate :validate_tenant_identity
|
|
32
|
+
before_destroy :assert_tenant_identity!
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def assign_tenant_identity
|
|
38
|
+
self.tenant_key ||= Tenancy.current_key
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def validate_tenant_identity
|
|
42
|
+
return unless Tenancy.enabled? || Tenancy.current_key
|
|
43
|
+
Tenancy.require_context!
|
|
44
|
+
errors.add(:tenant_key, "does not match the current tenant") unless tenant_key == Tenancy.current_key
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def assert_tenant_identity!
|
|
48
|
+
return unless Tenancy.enabled? || Tenancy.current_key
|
|
49
|
+
Tenancy.require_context!
|
|
50
|
+
raise ArgumentError, "record belongs to another tenant" unless tenant_key == Tenancy.current_key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_parent_tenant(parent, attribute)
|
|
54
|
+
errors.add(attribute, "belongs to another tenant") if parent && parent.tenant_key != tenant_key
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
class Mailbox < Cloudflare::Email::ActiveRecord::Base
|
|
59
|
+
include TenantIdentity
|
|
60
|
+
self.table_name = "cloudflare_email_mailboxes"
|
|
61
|
+
has_many :addresses, class_name: "Cloudflare::Email::Mailboxes::Address", dependent: :restrict_with_exception
|
|
62
|
+
has_many :messages, class_name: "Cloudflare::Email::Mailboxes::Message", dependent: :restrict_with_exception
|
|
63
|
+
has_many :outbound_messages, class_name: "Cloudflare::Email::Mailboxes::OutboundMessage", dependent: :restrict_with_exception
|
|
64
|
+
validates :name, presence: true, length: { maximum: 255 }
|
|
65
|
+
validates :state, inclusion: { in: %w[active suspended] }
|
|
66
|
+
scope :active, -> { where(state: "active") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
class Address < Cloudflare::Email::ActiveRecord::Base
|
|
70
|
+
include TenantIdentity
|
|
71
|
+
self.table_name = "cloudflare_email_addresses"
|
|
72
|
+
belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
|
|
73
|
+
attr_readonly :mailbox_id, :receiving_domain_id, :local_part, :domain, :address
|
|
74
|
+
before_validation :normalize_address, on: :create
|
|
75
|
+
validates :mailbox, :receiving_domain_id, presence: true
|
|
76
|
+
validates :local_part, length: { in: 1..64 },
|
|
77
|
+
format: { with: /\A[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+(?:\.[a-z0-9!\#$%&'*+\/=\?^_`{|}~-]+)*\z/ }
|
|
78
|
+
validates :address, uniqueness: true, length: { maximum: 254 }
|
|
79
|
+
validates :state, inclusion: { in: %w[pending active suspended] }
|
|
80
|
+
validate :validate_directory
|
|
81
|
+
validate :validate_catch_all
|
|
82
|
+
validate { validate_parent_tenant(mailbox, :mailbox) }
|
|
83
|
+
scope :active, -> { where(state: "active") }
|
|
84
|
+
|
|
85
|
+
def receiving_domain
|
|
86
|
+
ReceivingDomain.find_by(id: receiving_domain_id)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def normalize_address
|
|
92
|
+
self.local_part = local_part.to_s.strip.downcase
|
|
93
|
+
self.domain = domain.to_s.strip.downcase
|
|
94
|
+
self.address = "#{local_part}@#{domain}"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def validate_directory
|
|
98
|
+
registered = receiving_domain
|
|
99
|
+
unless registered && registered.domain == domain && registered.tenant_key == tenant_key
|
|
100
|
+
errors.add(:receiving_domain_id, "must match this tenant and domain")
|
|
101
|
+
return
|
|
102
|
+
end
|
|
103
|
+
if new_record? && registered.state != "active"
|
|
104
|
+
errors.add(:receiving_domain_id, "must be active before creating addresses")
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def validate_catch_all
|
|
109
|
+
# Existing exact-address installations need not migrate until they
|
|
110
|
+
# opt into catch-all receiving.
|
|
111
|
+
return unless has_attribute?(:catch_all) && self[:catch_all]
|
|
112
|
+
errors.add(:catch_all_evidence, "is required") if self[:catch_all_evidence].to_s.strip.empty?
|
|
113
|
+
if will_save_change_to_catch_all? && state != "active"
|
|
114
|
+
errors.add(:catch_all, "requires an active address")
|
|
115
|
+
end
|
|
116
|
+
return unless state == "active"
|
|
117
|
+
errors.add(:catch_all, "requires an active mailbox") unless mailbox&.state == "active"
|
|
118
|
+
errors.add(:catch_all, "requires an active receiving domain") unless receiving_domain&.state == "active"
|
|
119
|
+
if self.class.where(receiving_domain_id: receiving_domain_id, catch_all: true, state: "active").where.not(id: id).exists?
|
|
120
|
+
errors.add(:catch_all, "already exists for this receiving domain")
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class Message < Cloudflare::Email::ActiveRecord::Base
|
|
126
|
+
include TenantIdentity
|
|
127
|
+
self.table_name = "cloudflare_email_mailbox_messages"
|
|
128
|
+
belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
|
|
129
|
+
attr_readonly :mailbox_id, :inbound_email_id, :recipient
|
|
130
|
+
validates :mailbox, :inbound_email_id, :recipient, presence: true
|
|
131
|
+
# The unique database index also supports create_or_find_by! retries.
|
|
132
|
+
validate { validate_parent_tenant(mailbox, :mailbox) }
|
|
133
|
+
scope :unread, -> { where(read_at: nil) }
|
|
134
|
+
scope :inbox, -> { where(archived_at: nil) }
|
|
135
|
+
|
|
136
|
+
def mark_read!
|
|
137
|
+
update!(read_at: Time.current)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def mark_unread!
|
|
141
|
+
update!(read_at: nil)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def archive!
|
|
145
|
+
update!(archived_at: Time.current)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def unarchive!
|
|
149
|
+
update!(archived_at: nil)
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
class OutboundMessage < Cloudflare::Email::ActiveRecord::Base
|
|
154
|
+
include TenantIdentity
|
|
155
|
+
self.table_name = "cloudflare_email_mailbox_outbound_messages"
|
|
156
|
+
belongs_to :mailbox, class_name: "Cloudflare::Email::Mailboxes::Mailbox"
|
|
157
|
+
belongs_to :outbound_delivery, class_name: "Cloudflare::Email::ActiveRecord::OutboundDelivery"
|
|
158
|
+
attr_readonly :mailbox_id, :outbound_delivery_id
|
|
159
|
+
validates :mailbox, :outbound_delivery, presence: true
|
|
160
|
+
# Enforce uniqueness in the database so idempotent link creation works.
|
|
161
|
+
validate { validate_parent_tenant(mailbox, :mailbox) }
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
module Mailboxes
|
|
4
|
+
# Shared routing evidence, never inferred from a sender or event domain.
|
|
5
|
+
class ProviderCorrelation < Mailboxes.directory_base
|
|
6
|
+
self.table_name = "cloudflare_email_provider_correlations"
|
|
7
|
+
attr_readonly :account_id, :message_id, :recipient, :tenant_key, :outbound_delivery_id
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|