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.
Files changed (139) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +149 -1
  3. data/README.md +287 -385
  4. data/SECURITY.md +51 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +33 -21
  6. data/app/controllers/cloudflare/email/management/mailboxes_controller.rb +173 -0
  7. data/app/controllers/cloudflare/email/management/styles_controller.rb +13 -0
  8. data/app/views/cloudflare/email/management/mailboxes/index.html.erb +65 -0
  9. data/app/views/cloudflare/email/management/mailboxes/message.html.erb +34 -0
  10. data/app/views/cloudflare/email/management/mailboxes/show.html.erb +86 -0
  11. data/app/views/layouts/cloudflare/email/management.html.erb +29 -0
  12. data/docs/activerecord-tenanted.md +181 -0
  13. data/docs/architecture.md +105 -0
  14. data/docs/custom-ingress.md +207 -0
  15. data/docs/delivery-events.md +186 -0
  16. data/docs/features.md +138 -0
  17. data/docs/getting-started.md +308 -0
  18. data/docs/mailboxes.md +363 -0
  19. data/docs/management-engine.md +190 -0
  20. data/docs/outbox.md +175 -0
  21. data/docs/routing-deliveries.md +94 -0
  22. data/docs/routing-diagnostics.md +68 -0
  23. data/docs/tenant-mailboxes-plan.md +149 -0
  24. data/docs/thread-correlation.md +27 -0
  25. data/docs/troubleshooting.md +104 -0
  26. data/docs/upgrading-0.2.md +101 -0
  27. data/docs/upgrading-0.3.md +55 -0
  28. data/docs/verification/2026-09-10-followup.md +49 -0
  29. data/docs/verification/2026-09-10-install-upgrade.md +86 -0
  30. data/docs/verification/2026-09-10-live.md +102 -0
  31. data/docs/verification/2026-09-10.md +175 -0
  32. data/docs/verification/2026-09-11-custom-ingress.md +49 -0
  33. data/docs/verification/2026-09-11-extraction.md +32 -0
  34. data/docs/verification/2026-09-11-outbound-ledger.md +60 -0
  35. data/docs/verification/2026-09-11-security.md +76 -0
  36. data/docs/verification/2026-09-11-tenant-mailboxes.md +75 -0
  37. data/docs/verification/2026-09-12-destination-context.md +46 -0
  38. data/docs/verification/2026-09-13-durable-inbound-and-routing.md +71 -0
  39. data/examples/plain_ruby.rb +24 -0
  40. data/lib/cloudflare/email/active_record/base.rb +60 -0
  41. data/lib/cloudflare/email/active_record/delivery_events.rb +72 -0
  42. data/lib/cloudflare/email/active_record/event_inbox.rb +77 -0
  43. data/lib/cloudflare/email/active_record/event_receipt.rb +17 -0
  44. data/lib/cloudflare/email/active_record/mail_snapshot.rb +24 -0
  45. data/lib/cloudflare/email/active_record/outbound_delivery.rb +22 -0
  46. data/lib/cloudflare/email/active_record/outbound_recipient.rb +13 -0
  47. data/lib/cloudflare/email/active_record/outbound_reconciliation.rb +15 -0
  48. data/lib/cloudflare/email/active_record/outbox.rb +219 -0
  49. data/lib/cloudflare/email/active_record/outbox_notifications.rb +34 -0
  50. data/lib/cloudflare/email/active_record/routing_deliveries.rb +128 -0
  51. data/lib/cloudflare/email/active_record.rb +5 -0
  52. data/lib/cloudflare/email/check_route_task.rb +21 -0
  53. data/lib/cloudflare/email/client.rb +93 -18
  54. data/lib/cloudflare/email/consume_events_task.rb +22 -0
  55. data/lib/cloudflare/email/delivery_event.rb +70 -0
  56. data/lib/cloudflare/email/delivery_method.rb +5 -0
  57. data/lib/cloudflare/email/deploy_worker_task.rb +6 -3
  58. data/lib/cloudflare/email/dev_ingress_guard.rb +27 -0
  59. data/lib/cloudflare/email/dev_tunnel.rb +31 -5
  60. data/lib/cloudflare/email/doctor.rb +16 -38
  61. data/lib/cloudflare/email/endpoint.rb +24 -0
  62. data/lib/cloudflare/email/engine.rb +33 -0
  63. data/lib/cloudflare/email/envelope.rb +58 -0
  64. data/lib/cloudflare/email/event_consumer.rb +95 -0
  65. data/lib/cloudflare/email/ingress.rb +115 -0
  66. data/lib/cloudflare/email/mailboxes/configuration.rb +58 -0
  67. data/lib/cloudflare/email/mailboxes/events.rb +112 -0
  68. data/lib/cloudflare/email/mailboxes/inbound_retention.rb +14 -0
  69. data/lib/cloudflare/email/mailboxes/jobs.rb +34 -0
  70. data/lib/cloudflare/email/mailboxes/models.rb +165 -0
  71. data/lib/cloudflare/email/mailboxes/provider_correlation.rb +11 -0
  72. data/lib/cloudflare/email/mailboxes/service.rb +372 -0
  73. data/lib/cloudflare/email/mailboxes/shared_event_receipt.rb +14 -0
  74. data/lib/cloudflare/email/mailboxes.rb +11 -0
  75. data/lib/cloudflare/email/management/adapter.rb +31 -0
  76. data/lib/cloudflare/email/management/configuration.rb +17 -0
  77. data/lib/cloudflare/email/management/engine.rb +15 -0
  78. data/lib/cloudflare/email/management/management.css +68 -0
  79. data/lib/cloudflare/email/management/routes.rb +14 -0
  80. data/lib/cloudflare/email/management.rb +7 -0
  81. data/lib/cloudflare/email/message_id.rb +12 -0
  82. data/lib/cloudflare/email/provider_metadata.rb +77 -0
  83. data/lib/cloudflare/email/provision_catchall_task.rb +4 -4
  84. data/lib/cloudflare/email/provision_route_task.rb +2 -1
  85. data/lib/cloudflare/email/replay_events_job.rb +18 -0
  86. data/lib/cloudflare/email/response.rb +21 -3
  87. data/lib/cloudflare/email/routing_analytics.rb +196 -0
  88. data/lib/cloudflare/email/routing_diagnostics.rb +206 -0
  89. data/lib/cloudflare/email/routing_provisioner.rb +71 -45
  90. data/lib/cloudflare/email/send_job.rb +41 -0
  91. data/lib/cloudflare/email/send_test.rb +5 -29
  92. data/lib/cloudflare/email/signing.rb +2 -2
  93. data/lib/cloudflare/email/tenancy.rb +78 -0
  94. data/lib/cloudflare/email/tenant_job_context.rb +90 -0
  95. data/lib/cloudflare/email/verification.rb +38 -8
  96. data/lib/cloudflare/email/version.rb +1 -1
  97. data/lib/cloudflare/email/worker_deployer.rb +41 -8
  98. data/lib/cloudflare-email.rb +4 -1
  99. data/lib/generators/cloudflare/email/install_generator.rb +53 -37
  100. data/lib/generators/cloudflare/email/mailboxes/catch_all/catch_all_generator.rb +21 -0
  101. data/lib/generators/cloudflare/email/mailboxes/catch_all/templates/add_cloudflare_email_catch_all.rb +18 -0
  102. data/lib/generators/cloudflare/email/mailboxes/mailboxes_generator.rb +55 -0
  103. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_mailboxes.rb +58 -0
  104. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_receiving_domains.rb +16 -0
  105. data/lib/generators/cloudflare/email/mailboxes/templates/create_cloudflare_email_shared_events.rb +35 -0
  106. data/lib/generators/cloudflare/email/outbox/outbox_generator.rb +28 -0
  107. data/lib/generators/cloudflare/email/outbox/templates/create_cloudflare_email_outbox.rb +48 -0
  108. data/lib/generators/cloudflare/email/routing_tracking/routing_tracking_generator.rb +28 -0
  109. data/lib/generators/cloudflare/email/routing_tracking/templates/create_cloudflare_email_routing_delivery_receipts.rb +22 -0
  110. data/lib/generators/cloudflare/email/templates/initializer.rb +3 -3
  111. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +2 -7
  112. data/lib/generators/cloudflare/email/tracking/templates/create_cloudflare_email_event_receipts.rb +22 -0
  113. data/lib/generators/cloudflare/email/tracking/tracking_generator.rb +26 -0
  114. data/lib/tasks/cloudflare_email.rake +42 -4
  115. data/lib/tasks/cloudflare_email_diagnostics.rake +11 -0
  116. data/templates/deploy-to-cloudflare/.dev.vars.example +3 -0
  117. data/templates/deploy-to-cloudflare/.gitignore +6 -0
  118. data/templates/deploy-to-cloudflare/LICENSE.txt +21 -0
  119. data/templates/deploy-to-cloudflare/README.md +84 -0
  120. data/templates/deploy-to-cloudflare/package-lock.json +2627 -0
  121. data/templates/deploy-to-cloudflare/package.json +36 -0
  122. data/templates/deploy-to-cloudflare/src/index.js +445 -0
  123. data/templates/deploy-to-cloudflare/test/durable.test.ts +193 -0
  124. data/templates/deploy-to-cloudflare/test/index.test.ts +309 -0
  125. data/templates/deploy-to-cloudflare/test/relay.test.ts +226 -0
  126. data/templates/deploy-to-cloudflare/vitest.config.ts +8 -0
  127. data/templates/deploy-to-cloudflare/wrangler.toml +22 -0
  128. data/templates/worker/README.md +231 -16
  129. data/templates/worker/docs/durable-inbound.md +149 -0
  130. data/templates/worker/package-lock.json +2627 -0
  131. data/templates/worker/package.json +8 -5
  132. data/templates/worker/scripts/wrangler.mjs +16 -0
  133. data/templates/worker/src/index.js +397 -25
  134. data/templates/worker/test/durable.test.ts +193 -0
  135. data/templates/worker/test/index.test.ts +183 -13
  136. data/templates/worker/test/relay.test.ts +226 -0
  137. data/templates/worker/wrangler.toml +63 -4
  138. metadata +154 -7
  139. data/lib/cloudflare/email/secure_message_id.rb +0 -89
data/SECURITY.md ADDED
@@ -0,0 +1,51 @@
1
+ # Security
2
+
3
+ The hardening described here is included in 0.2.0. Version 0.1.0 does not include
4
+ these fixes. Upgrade Rails and the bundled Worker together using the
5
+ [upgrade guide](docs/upgrading-0.2.md).
6
+
7
+ Rails integration is tested on patched Rails 7.2, 8.0, and 8.1; application
8
+ owners must update their own Rails, database adapter, and other dependencies.
9
+ The gem does not add Rails or SQLite as runtime dependencies.
10
+
11
+ Report sensitive findings privately to the maintainer at cole@dscribeai.com.
12
+ Include affected versions and impact; omit real credentials and private mail.
13
+ Do not place secrets or private message data in public issues.
14
+
15
+ ## Deployment responsibilities
16
+
17
+ - Keep API and management tokens least-privileged and separate. Store tokens and
18
+ a strong ingress secret in a secret manager or Rails encrypted credentials.
19
+ Rotate compromised secrets on Rails and the Worker together. HTTPS endpoint
20
+ overrides are trusted operator configuration; never derive them from mail.
21
+ - Set request-size limits, timeouts and rate limits at the proxy/server. Rails
22
+ and the Worker bound MIME reads to 25 MiB by default (`MAX_EMAIL_BYTES`), but
23
+ cannot prevent upstream buffering or slow clients. Limits apply to raw MIME,
24
+ not just decoded attachments.
25
+ - Treat mail and attachments as untrusted content. The gem authenticates Worker
26
+ transport and SMTP envelope metadata, not a human sender's identity. Rendering,
27
+ sanitization, malware scanning and application authorization belong to the app.
28
+ - Protect raw MIME, attachments, outbox snapshots and event receipts with access
29
+ control, storage encryption, backups and retention policies. Avoid logging raw
30
+ responses/errors: provider details and application logs can contain private mail.
31
+ - Restrict queue producers and consumers. Event account/domain checks are
32
+ validation, not cryptographic signatures. Monitor unacknowledged malformed
33
+ messages, processing failures and unmatched receipts.
34
+ - Database administrators and direct SQL writes are trusted. ActiveRecord
35
+ read-only evidence fields do not provide tamper-proof storage. Keep tenant
36
+ authorization around outbox operations and receipt access in the application.
37
+ - The optional mailbox session scopes tenant and mailbox access, but does not
38
+ authenticate users. Keep domain registration/activation administrative, verify
39
+ ownership externally, and use your access resolver to choose tenant keys.
40
+ Ingress selects a registered tenant only after full signature verification.
41
+ Raw ActiveRecord access remains privileged, especially in shared-database mode.
42
+ Private tenant storage, consistent migrations and trusted job payloads are
43
+ required. Drain old framework jobs before enabling database tenancy; jobs
44
+ without the new tenant metadata are rejected rather than guessing a tenant.
45
+ - Development tunnels expose the ingress to the internet. Use the bundled task,
46
+ dedicated development mail routes and test data; stop tunnels when finished.
47
+ The Host guard restricts routing but is not a sandbox for the development app
48
+ or a replacement for safe error pages and request logging.
49
+
50
+ See [the September 11 security review](docs/verification/2026-09-11-security.md)
51
+ for findings, verification, and remaining limits.
@@ -1,41 +1,52 @@
1
- require "cloudflare/email/verification"
1
+ require "cloudflare/email/ingress"
2
2
 
3
3
  module Cloudflare
4
4
  module Email
5
5
  # ActionMailbox ingress for Cloudflare Email Worker forwards.
6
6
  #
7
- # The shipped Worker template signs each forwarded message with HMAC-SHA256
8
- # over "{timestamp}.{raw_body}" and sends:
9
- # X-CF-Email-Timestamp: <unix seconds>
10
- # X-CF-Email-Signature: <hex digest>
7
+ # The default Worker uses v2 authenticated SMTP envelopes; custom Workers
8
+ # can opt into v3 to authenticate additional provider metadata. Both use
9
+ # the shared, bounded Ingress verifier before any tenant lookup or storage.
11
10
  #
12
11
  # Set the shared secret in Rails credentials under cloudflare.ingress_secret
13
12
  # (or in the CLOUDFLARE_INGRESS_SECRET env var) and as the Worker secret
14
13
  # INGRESS_SECRET via `wrangler secret put INGRESS_SECRET`.
15
14
  class IngressController < ActionMailbox::BaseController
15
+ DEFAULT_MAX_EMAIL_BYTES = 25 * 1024 * 1024
16
16
  param_encoding :create, "raw_email", Encoding::ASCII_8BIT
17
17
 
18
18
  def create
19
19
  ActiveSupport::Notifications.instrument(
20
20
  "cloudflare_email.ingress",
21
- bytes: raw_body.bytesize,
21
+ bytes: 0,
22
22
  ) do |payload|
23
- case Cloudflare::Email::Verification.verify(
24
- secret: secret,
25
- body: raw_body,
26
- timestamp: request.headers["X-CF-Email-Timestamp"],
27
- signature: request.headers["X-CF-Email-Signature"],
28
- )
23
+ request.body.rewind if request.body.respond_to?(:rewind)
24
+ result = Cloudflare::Email::Ingress.verify(secret: secret,
25
+ headers: request.headers, body: request.body,
26
+ content_length: request.content_length, max_email_bytes: max_email_bytes)
27
+ payload[:bytes] = result.bytes.to_i
28
+ payload[:result] = result.status
29
+ case result.status
29
30
  when :stale
30
- payload[:result] = :stale
31
31
  head :request_timeout
32
32
  when :bad_signature
33
- payload[:result] = :bad_signature
34
33
  head :unauthorized
34
+ when :too_large
35
+ head :payload_too_large
35
36
  when :ok
36
- inbound = ActionMailbox::InboundEmail.create_and_extract_message_id!(raw_body)
37
- payload[:result] = :ok
38
- payload[:message_id] = inbound.message_id
37
+ inbound = if defined?(Cloudflare::Email::Mailboxes) && Cloudflare::Email::Mailboxes.enabled?
38
+ recipient = result.message.envelope.fetch("to")
39
+ begin
40
+ Cloudflare::Email::Mailboxes.receive(recipient: recipient) { result.message.persist_action_mailbox! }
41
+ rescue Cloudflare::Email::Mailboxes::Unavailable
42
+ payload[:result] = :unavailable_mailbox
43
+ next head(:unprocessable_entity)
44
+ end
45
+ else
46
+ result.message.persist_action_mailbox!
47
+ end
48
+ payload[:result] = inbound ? :ok : :duplicate
49
+ payload[:message_id] = inbound&.message_id
39
50
  head :ok
40
51
  end
41
52
  end
@@ -49,10 +60,11 @@ module Cloudflare
49
60
  :cloudflare
50
61
  end
51
62
 
52
- def raw_body
53
- @raw_body ||= begin
54
- request.body.rewind if request.body.respond_to?(:rewind)
55
- request.body.read
63
+ def max_email_bytes
64
+ @max_email_bytes ||= begin
65
+ value = Integer(ENV.fetch("MAX_EMAIL_BYTES", DEFAULT_MAX_EMAIL_BYTES.to_s), 10)
66
+ raise ArgumentError, "MAX_EMAIL_BYTES must be positive" unless value.positive?
67
+ value
56
68
  end
57
69
  end
58
70
 
@@ -0,0 +1,173 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Management
4
+ class MailboxesController < ActionController::Base
5
+ layout "layouts/cloudflare/email/management"
6
+ protect_from_forgery with: :exception
7
+ self.forgery_protection_origin_check = true
8
+ around_action :with_host_context
9
+ helper_method :allowed?, :host_back_path
10
+
11
+ class Denied < StandardError; end
12
+ class InvalidInput < StandardError; end
13
+
14
+ rescue_from Denied do
15
+ head :forbidden
16
+ end
17
+ rescue_from "ActiveRecord::RecordNotFound" do
18
+ head :not_found
19
+ end
20
+ rescue_from InvalidInput, "ActiveRecord::RecordInvalid", Cloudflare::Email::Error do
21
+ redirect_to(@mailbox ? mailbox_path(@mailbox) : root_path,
22
+ alert: "The changes could not be saved. Check the address, registered domain and required fields.",
23
+ status: :see_other)
24
+ end
25
+ rescue_from Cloudflare::Email::ConfigurationError do
26
+ head :service_unavailable
27
+ end
28
+
29
+ def index
30
+ authorize!(:index)
31
+ @mailboxes = page(scoped_mailboxes)
32
+ @domains = permitted_domains if allowed?(:create)
33
+ end
34
+
35
+ def show
36
+ load_mailbox!(:show)
37
+ @addresses = @session.addresses(@mailbox.id).order(:id).to_a
38
+ @domains = allowed?(:add_address, @mailbox) ? permitted_domains : []
39
+ @messages = allowed?(:show_message, @mailbox) ? page(@session.messages(@mailbox.id)) : []
40
+ end
41
+
42
+ def create
43
+ authorize!(:create)
44
+ input = params.require(:mailbox)
45
+ raise InvalidInput unless input.is_a?(ActionController::Parameters)
46
+ values = input.permit(:name, :address)
47
+ name = values[:name].to_s.strip
48
+ raise InvalidInput unless (1..255).cover?(name.length)
49
+ address = permitted_address!(values[:address])
50
+ Mailboxes::Mailbox.transaction do
51
+ @mailbox = @adapter.create_mailbox(@session, name: name, address: address)
52
+ # A hook cannot turn an arbitrary return value into a readable mailbox.
53
+ @mailbox = scoped_mailboxes.find(@mailbox.id)
54
+ end
55
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox created. Review address setup below.", status: :see_other
56
+ end
57
+
58
+ def add_address
59
+ load_mailbox!(:add_address)
60
+ @adapter.add_address(@session, @mailbox, address: permitted_address!(params[:address]))
61
+ redirect_to mailbox_path(@mailbox), notice: "Address added. Review its setup status.", status: :see_other
62
+ end
63
+
64
+ def suspend
65
+ load_mailbox!(:suspend)
66
+ @session.suspend(@mailbox.id)
67
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox suspended. Messages are retained.", status: :see_other
68
+ end
69
+
70
+ def resume
71
+ load_mailbox!(:resume)
72
+ @session.resume(@mailbox.id)
73
+ redirect_to mailbox_path(@mailbox), notice: "Mailbox resumed. Pending addresses still need setup.", status: :see_other
74
+ end
75
+
76
+ def message
77
+ load_mailbox!(:show_message)
78
+ @entry = @session.messages(@mailbox.id).find(params[:message_id])
79
+ raw = @session.inbound_email(@mailbox.id, @entry.id).mail
80
+ @subject = raw.subject.to_s
81
+ @from = Array(raw.from).join(", ")
82
+ part = raw.text_part || (raw.mime_type == "text/plain" ? raw : nil)
83
+ @body = part ? part.decoded.to_s.encode("UTF-8", invalid: :replace, undef: :replace).first(100_000) :
84
+ "This message has no plain-text body. HTML rendering is disabled in this management interface."
85
+ @attachments = raw.attachments.map { |attachment| attachment.filename.to_s }
86
+ render :message
87
+ end
88
+
89
+ def mark_read
90
+ load_mailbox!(:mark_read)
91
+ @session.mark_read(@mailbox.id, params[:message_id], read: boolean_param(:read))
92
+ redirect_to mailbox_path(@mailbox), status: :see_other
93
+ end
94
+
95
+ def archive
96
+ load_mailbox!(:archive)
97
+ @session.archive(@mailbox.id, params[:message_id], archived: boolean_param(:archived))
98
+ redirect_to mailbox_path(@mailbox), status: :see_other
99
+ end
100
+
101
+ private
102
+
103
+ def with_host_context
104
+ response.headers["Cache-Control"] = "no-store"
105
+ response.headers["Referrer-Policy"] = "same-origin"
106
+ response.headers["X-Content-Type-Options"] = "nosniff"
107
+ response.headers["Content-Security-Policy"] = "default-src 'none'; style-src 'self'; img-src 'none'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"
108
+ factory = Management.configuration.adapter
109
+ unless factory.respond_to?(:call) && defined?(Mailboxes) && Mailboxes.enabled?
110
+ return head :service_unavailable
111
+ end
112
+ @adapter = factory.call(self)
113
+ authenticated = @adapter.authenticate!
114
+ return if performed?
115
+ return head :unauthorized unless authenticated == true
116
+ Mailboxes.for_tenant(@adapter.tenant_key) do |session|
117
+ @session = session
118
+ yield
119
+ end
120
+ end
121
+
122
+ def allowed?(action, mailbox = nil)
123
+ @adapter.allowed?(action, mailbox) == true
124
+ end
125
+
126
+ def authorize!(action, mailbox = nil)
127
+ raise Denied unless allowed?(action, mailbox)
128
+ end
129
+
130
+ def scoped_mailboxes
131
+ # Always retain the gem's tenant condition even if a host scope omits it.
132
+ @session.mailboxes.where(id: @adapter.mailboxes(@session).reselect(:id))
133
+ end
134
+
135
+ def load_mailbox!(action)
136
+ @mailbox = scoped_mailboxes.find(params[:id])
137
+ authorize!(action, @mailbox)
138
+ end
139
+
140
+ def permitted_domains
141
+ granted = Array(@adapter.domains(@session)).map(&:to_s)
142
+ Mailboxes::ReceivingDomain.active.where(tenant_key: @session.tenant_key, domain: granted).order(:domain).pluck(:domain)
143
+ end
144
+
145
+ def permitted_address!(value)
146
+ address = Mailboxes.canonical_address(value)
147
+ raise InvalidInput unless permitted_domains.include?(address.split("@", 2).last)
148
+ address
149
+ end
150
+
151
+ def page(relation)
152
+ cursor = params[:after].to_s
153
+ raise InvalidInput unless cursor.empty? || cursor.match?(/\A[0-9]{1,18}\z/)
154
+ rows = relation.where("id > ?", cursor.to_i).order(:id).limit(51).to_a
155
+ @next_cursor = rows.length > 50 ? rows[49].id : nil
156
+ rows.first(50)
157
+ end
158
+
159
+ def boolean_param(key)
160
+ value = params[key]
161
+ raise InvalidInput unless %w[true false].include?(value)
162
+ value == "true"
163
+ end
164
+
165
+ def host_back_path
166
+ callback = Management.configuration.back_path
167
+ value = callback.call(self) if callback.respond_to?(:call)
168
+ value if value.is_a?(String) && value.start_with?("/") && !value.start_with?("//") && !value.match?(/[\\\r\n]/)
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,13 @@
1
+ module Cloudflare
2
+ module Email
3
+ module Management
4
+ class StylesController < ActionController::Base
5
+ def show
6
+ response.headers["X-Content-Type-Options"] = "nosniff"
7
+ send_data File.binread(File.expand_path("../../../../../lib/cloudflare/email/management/management.css", __dir__)),
8
+ type: "text/css; charset=utf-8", disposition: "inline"
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,65 @@
1
+ <div class="page-heading">
2
+ <div>
3
+ <p class="eyebrow">Email workspace</p>
4
+ <h1>Mailboxes</h1>
5
+ <p class="muted">Manage accepted addresses and the messages delivered to them.</p>
6
+ </div>
7
+ </div>
8
+
9
+ <div class="workspace-grid">
10
+ <section class="panel" aria-labelledby="mailboxes-heading">
11
+ <div class="panel-heading"><h2 id="mailboxes-heading">Your mailboxes</h2></div>
12
+ <% if @mailboxes.empty? %>
13
+ <div class="empty-state">
14
+ <h3>No mailboxes to show</h3>
15
+ <p>Create a mailbox to give incoming email a home.</p>
16
+ </div>
17
+ <% else %>
18
+ <ul class="record-list">
19
+ <% @mailboxes.each do |mailbox| %>
20
+ <li class="record-row">
21
+ <div class="record-content">
22
+ <% if allowed?(:show, mailbox) %>
23
+ <%= link_to mailbox.name, mailbox_path(mailbox.id), class: "record-title" %>
24
+ <% else %>
25
+ <span class="record-title"><%= mailbox.name %></span>
26
+ <% end %>
27
+ <p class="muted small">Mailbox #<%= mailbox.id %></p>
28
+ </div>
29
+ <span class="badge"><%= mailbox.state.capitalize %></span>
30
+ </li>
31
+ <% end %>
32
+ </ul>
33
+ <% end %>
34
+ <% if @next_cursor.present? %>
35
+ <nav class="pagination" aria-label="Mailbox pages">
36
+ <%= link_to "Next mailboxes →", root_path(after: @next_cursor), class: "button button-secondary" %>
37
+ </nav>
38
+ <% end %>
39
+ </section>
40
+
41
+ <% if allowed?(:create) %>
42
+ <section class="panel" aria-labelledby="create-heading">
43
+ <div class="panel-heading"><h2 id="create-heading">Create a mailbox</h2></div>
44
+ <div class="panel-body">
45
+ <% if @domains.empty? %>
46
+ <p class="muted">An administrator needs to register an active receiving domain before you can create a mailbox.</p>
47
+ <% else %>
48
+ <%= form_with url: mailboxes_path, scope: :mailbox, local: true, class: "stack" do |form| %>
49
+ <div class="field">
50
+ <%= form.label :name, "Mailbox name" %>
51
+ <%= form.text_field :name, required: true, maxlength: 255, placeholder: "Customer support" %>
52
+ </div>
53
+ <div class="field">
54
+ <%= form.label :address, "Email address" %>
55
+ <%= form.email_field :address, required: true, maxlength: 254, placeholder: "support@#{@domains.first}", aria: { describedby: "create-domains" } %>
56
+ <p id="create-domains" class="field-help">Available domains: <%= @domains.join(", ") %></p>
57
+ </div>
58
+ <p class="field-help">New addresses start pending. Your administrator must confirm their routing before they can receive mail.</p>
59
+ <%= form.submit "Create mailbox", class: "button" %>
60
+ <% end %>
61
+ <% end %>
62
+ </div>
63
+ </section>
64
+ <% end %>
65
+ </div>
@@ -0,0 +1,34 @@
1
+ <nav class="breadcrumbs" aria-label="Breadcrumb">
2
+ <%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span>
3
+ <%= link_to @mailbox.name, mailbox_path(@mailbox.id) %> <span aria-hidden="true">/</span>
4
+ <span>Message</span>
5
+ </nav>
6
+ <div class="page-heading">
7
+ <div><p class="eyebrow">Received message</p><h1><%= @subject.presence || "(No subject)" %></h1></div>
8
+ <div class="actions">
9
+ <% if allowed?(:mark_read, @mailbox) %>
10
+ <%= button_to(@entry.read_at ? "Mark unread" : "Mark read", mark_read_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, read: @entry.read_at ? "false" : "true" }, class: "button button-secondary") %>
11
+ <% end %>
12
+ <% if allowed?(:archive, @mailbox) %>
13
+ <%= button_to(@entry.archived_at ? "Unarchive" : "Archive", archive_mailbox_path(@mailbox.id), method: :post, params: { message_id: @entry.id, archived: @entry.archived_at ? "false" : "true" }, class: "button button-secondary") %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+ <article class="panel" aria-label="Email message">
18
+ <div class="panel-body">
19
+ <dl class="message-metadata">
20
+ <dt>From</dt><dd><%= @from.presence || "(Unknown sender)" %></dd>
21
+ <dt>To</dt><dd><%= @entry.recipient %></dd>
22
+ <dt>Received</dt><dd><time datetime="<%= @entry.created_at.iso8601 %>"><%= @entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></dd>
23
+ <dt>Status</dt><dd><%= @entry.read_at ? "Read" : "Unread" %><%= " · Archived" if @entry.archived_at %></dd>
24
+ </dl>
25
+ </div>
26
+ <div class="message-body"><pre><%= @body.presence || "No text content is available for this message." %></pre></div>
27
+ <% if @attachments.any? %>
28
+ <section class="panel-body attachments" aria-labelledby="attachments-heading">
29
+ <h2 id="attachments-heading">Attachments</h2>
30
+ <p class="field-help">File names are shown for reference. Downloads are not available in this dashboard.</p>
31
+ <ul><% @attachments.each do |filename| %><li><%= filename.presence || "Unnamed attachment" %></li><% end %></ul>
32
+ </section>
33
+ <% end %>
34
+ </article>
@@ -0,0 +1,86 @@
1
+ <nav class="breadcrumbs" aria-label="Breadcrumb"><%= link_to "Mailboxes", root_path %> <span aria-hidden="true">/</span> <span><%= @mailbox.name %></span></nav>
2
+ <div class="page-heading">
3
+ <div>
4
+ <p class="eyebrow">Mailbox</p>
5
+ <h1><%= @mailbox.name %></h1>
6
+ <p class="muted"><%= @mailbox.state == "suspended" ? "Receiving is paused for every address in this mailbox." : "Addresses receive mail when both their address and domain are active." %></p>
7
+ </div>
8
+ <div class="actions">
9
+ <span class="badge"><%= @mailbox.state.capitalize %></span>
10
+ <% if @mailbox.state == "active" && allowed?(:suspend, @mailbox) %>
11
+ <%= button_to "Suspend mailbox", suspend_mailbox_path(@mailbox.id), method: :post, class: "button button-secondary" %>
12
+ <% elsif @mailbox.state == "suspended" && allowed?(:resume, @mailbox) %>
13
+ <%= button_to "Resume mailbox", resume_mailbox_path(@mailbox.id), method: :post, class: "button" %>
14
+ <% end %>
15
+ </div>
16
+ </div>
17
+
18
+ <div class="workspace-grid">
19
+ <section class="panel" aria-labelledby="messages-heading">
20
+ <div class="panel-heading"><h2 id="messages-heading">Received messages</h2><p class="muted small">Includes archived messages</p></div>
21
+ <% if @messages.empty? %>
22
+ <div class="empty-state"><h3>No messages to show</h3><p>Messages appear here after delivery to an active address.</p></div>
23
+ <% else %>
24
+ <ul class="record-list">
25
+ <% @messages.each do |entry| %>
26
+ <li class="record-row">
27
+ <div class="record-content">
28
+ <% if allowed?(:show_message, @mailbox) %>
29
+ <%= link_to "Message to #{entry.recipient}", message_mailbox_path(@mailbox.id, message_id: entry.id), class: "record-title" %>
30
+ <% else %>
31
+ <span class="record-title">Message to <%= entry.recipient %></span>
32
+ <% end %>
33
+ <p class="muted small"><time datetime="<%= entry.created_at.iso8601 %>"><%= entry.created_at.utc.strftime("%b %-d, %Y · %H:%M UTC") %></time></p>
34
+ </div>
35
+ <div class="status-list">
36
+ <span class="badge"><%= entry.read_at ? "Read" : "Unread" %></span>
37
+ <% if entry.archived_at %><span class="badge">Archived</span><% end %>
38
+ </div>
39
+ </li>
40
+ <% end %>
41
+ </ul>
42
+ <% end %>
43
+ <% if @next_cursor.present? %>
44
+ <nav class="pagination" aria-label="Message pages"><%= link_to "Next messages →", mailbox_path(@mailbox.id, after: @next_cursor), class: "button button-secondary" %></nav>
45
+ <% end %>
46
+ </section>
47
+
48
+ <div class="stack">
49
+ <section class="panel" aria-labelledby="addresses-heading">
50
+ <div class="panel-heading"><h2 id="addresses-heading">Addresses</h2></div>
51
+ <ul class="record-list">
52
+ <% @addresses.each do |address| %>
53
+ <% receiving = @mailbox.state == "active" && address.state == "active" && address.receiving_domain&.state == "active" %>
54
+ <li class="record-row address-row">
55
+ <span class="record-title"><%= address.address %></span>
56
+ <% if address.has_attribute?(:catch_all) && address[:catch_all] %>
57
+ <span class="badge">Domain catch-all configured</span>
58
+ <% end %>
59
+ <span class="badge"><%= receiving ? "Accepting mail" : address.state == "pending" ? "Pending setup" : "Receiving paused" %></span>
60
+ </li>
61
+ <% end %>
62
+ </ul>
63
+ <div class="panel-body"><p class="field-help">Pending addresses need routing confirmation from your administrator. Resuming a mailbox preserves each address’s existing setup state.</p></div>
64
+ </section>
65
+
66
+ <% if allowed?(:add_address, @mailbox) %>
67
+ <section class="panel" aria-labelledby="alias-heading">
68
+ <div class="panel-heading"><h2 id="alias-heading">Add an alias</h2></div>
69
+ <div class="panel-body">
70
+ <% if @domains.empty? %>
71
+ <p class="muted">No active receiving domains are available. Contact your administrator.</p>
72
+ <% else %>
73
+ <%= form_with url: aliases_mailbox_path(@mailbox.id), local: true, class: "stack" do |form| %>
74
+ <div class="field">
75
+ <%= form.label :address, "Alias email address" %>
76
+ <%= form.email_field :address, required: true, maxlength: 254, aria: { describedby: "alias-domains" } %>
77
+ <p id="alias-domains" class="field-help">Available domains: <%= @domains.join(", ") %>. Messages to this alias will appear in this mailbox.</p>
78
+ </div>
79
+ <%= form.submit "Add alias", class: "button" %>
80
+ <% end %>
81
+ <% end %>
82
+ </div>
83
+ </section>
84
+ <% end %>
85
+ </div>
86
+ </div>
@@ -0,0 +1,29 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Mailbox management</title>
7
+ <%= csrf_meta_tags %>
8
+ <%= stylesheet_link_tag style_path %>
9
+ </head>
10
+ <body>
11
+ <a class="skip-link" href="#main">Skip to content</a>
12
+ <header class="site-header">
13
+ <div class="shell header-content">
14
+ <%= link_to "Mailbox management", root_path, class: "brand" %>
15
+ <span class="header-caption">Cloudflare Email</span>
16
+ <% if host_back_path %><%= link_to "Back to app", host_back_path %><% end %>
17
+ </div>
18
+ </header>
19
+ <main id="main" class="shell" tabindex="-1">
20
+ <% if flash[:notice].present? %>
21
+ <div class="notice" role="status"><%= flash[:notice] %></div>
22
+ <% end %>
23
+ <% if flash[:alert].present? %>
24
+ <div class="notice notice-error" role="alert"><%= flash[:alert] %></div>
25
+ <% end %>
26
+ <%= yield %>
27
+ </main>
28
+ </body>
29
+ </html>