cloudflare-email 0.1.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 +7 -0
- data/CHANGELOG.md +87 -0
- data/LICENSE.txt +21 -0
- data/README.md +521 -0
- data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
- data/lib/cloudflare/email/client.rb +216 -0
- data/lib/cloudflare/email/credentials.rb +59 -0
- data/lib/cloudflare/email/delivery_method.rb +57 -0
- data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
- data/lib/cloudflare/email/dev_tunnel.rb +97 -0
- data/lib/cloudflare/email/doctor.rb +244 -0
- data/lib/cloudflare/email/engine.rb +25 -0
- data/lib/cloudflare/email/error.rb +20 -0
- data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
- data/lib/cloudflare/email/provision_route_task.rb +34 -0
- data/lib/cloudflare/email/response.rb +61 -0
- data/lib/cloudflare/email/routing_provisioner.rb +220 -0
- data/lib/cloudflare/email/secure_message_id.rb +89 -0
- data/lib/cloudflare/email/send_test.rb +75 -0
- data/lib/cloudflare/email/signing.rb +37 -0
- data/lib/cloudflare/email/task_base.rb +61 -0
- data/lib/cloudflare/email/verification.rb +44 -0
- data/lib/cloudflare/email/version.rb +5 -0
- data/lib/cloudflare/email/worker_deployer.rb +183 -0
- data/lib/cloudflare-email.rb +9 -0
- data/lib/generators/cloudflare/email/install_generator.rb +227 -0
- data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
- data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
- data/lib/tasks/cloudflare_email.rake +45 -0
- data/templates/worker/README.md +37 -0
- data/templates/worker/package.json +15 -0
- data/templates/worker/src/index.js +73 -0
- data/templates/worker/test/index.test.ts +139 -0
- data/templates/worker/vitest.config.ts +8 -0
- data/templates/worker/wrangler.toml +10 -0
- metadata +155 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "cloudflare/email/verification"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# ActionMailbox ingress for Cloudflare Email Worker forwards.
|
|
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>
|
|
11
|
+
#
|
|
12
|
+
# Set the shared secret in Rails credentials under cloudflare.ingress_secret
|
|
13
|
+
# (or in the CLOUDFLARE_INGRESS_SECRET env var) and as the Worker secret
|
|
14
|
+
# INGRESS_SECRET via `wrangler secret put INGRESS_SECRET`.
|
|
15
|
+
class IngressController < ActionMailbox::BaseController
|
|
16
|
+
param_encoding :create, "raw_email", Encoding::ASCII_8BIT
|
|
17
|
+
|
|
18
|
+
def create
|
|
19
|
+
ActiveSupport::Notifications.instrument(
|
|
20
|
+
"cloudflare_email.ingress",
|
|
21
|
+
bytes: raw_body.bytesize,
|
|
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
|
+
)
|
|
29
|
+
when :stale
|
|
30
|
+
payload[:result] = :stale
|
|
31
|
+
head :request_timeout
|
|
32
|
+
when :bad_signature
|
|
33
|
+
payload[:result] = :bad_signature
|
|
34
|
+
head :unauthorized
|
|
35
|
+
when :ok
|
|
36
|
+
inbound = ActionMailbox::InboundEmail.create_and_extract_message_id!(raw_body)
|
|
37
|
+
payload[:result] = :ok
|
|
38
|
+
payload[:message_id] = inbound.message_id
|
|
39
|
+
head :ok
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# Override ActionMailbox::BaseController's default name inference so
|
|
47
|
+
# `config.action_mailbox.ingress = :cloudflare` gates this controller.
|
|
48
|
+
def ingress_name
|
|
49
|
+
:cloudflare
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def raw_body
|
|
53
|
+
@raw_body ||= begin
|
|
54
|
+
request.body.rewind if request.body.respond_to?(:rewind)
|
|
55
|
+
request.body.read
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def secret
|
|
60
|
+
@secret ||= begin
|
|
61
|
+
require "cloudflare/email/credentials"
|
|
62
|
+
Cloudflare::Email::Credentials.ingress_secret
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module Cloudflare
|
|
6
|
+
module Email
|
|
7
|
+
class Client
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.cloudflare.com/client/v4".freeze
|
|
9
|
+
DEFAULT_RETRIES = 3
|
|
10
|
+
DEFAULT_TIMEOUT = 30
|
|
11
|
+
DEFAULT_BACKOFF = 0.5
|
|
12
|
+
MAX_RETRY_AFTER = 60 # seconds; never sleep longer than this even if server says so
|
|
13
|
+
|
|
14
|
+
RETRYABLE_NETWORK = [
|
|
15
|
+
Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET,
|
|
16
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, EOFError, SocketError,
|
|
17
|
+
IOError
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :account_id, :base_url, :retries, :timeout
|
|
21
|
+
|
|
22
|
+
def initialize(account_id:, api_token:, base_url: DEFAULT_BASE_URL,
|
|
23
|
+
retries: DEFAULT_RETRIES, timeout: DEFAULT_TIMEOUT,
|
|
24
|
+
initial_backoff: DEFAULT_BACKOFF, max_retry_after: MAX_RETRY_AFTER,
|
|
25
|
+
logger: nil)
|
|
26
|
+
raise ConfigurationError, "account_id is required" if account_id.nil? || account_id.to_s.empty?
|
|
27
|
+
raise ConfigurationError, "api_token is required" if api_token.nil? || api_token.to_s.empty?
|
|
28
|
+
|
|
29
|
+
@account_id = account_id
|
|
30
|
+
@api_token = api_token
|
|
31
|
+
@base_url = base_url
|
|
32
|
+
@retries = retries
|
|
33
|
+
@timeout = timeout
|
|
34
|
+
@initial_backoff = initial_backoff
|
|
35
|
+
@max_retry_after = max_retry_after
|
|
36
|
+
@logger = logger
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def send(from:, to:, subject:, text: nil, html: nil, cc: nil, bcc: nil,
|
|
40
|
+
reply_to: nil, headers: nil, attachments: nil)
|
|
41
|
+
raise ValidationError, "must provide :text or :html" if text.nil? && html.nil?
|
|
42
|
+
|
|
43
|
+
body = {
|
|
44
|
+
from: normalize_address(from),
|
|
45
|
+
to: wrap(to).map { |addr| normalize_address(addr) },
|
|
46
|
+
subject: subject,
|
|
47
|
+
}
|
|
48
|
+
body[:text] = text if text
|
|
49
|
+
body[:html] = html if html
|
|
50
|
+
body[:cc] = wrap(cc).map { |a| normalize_address(a) } if cc
|
|
51
|
+
body[:bcc] = wrap(bcc).map { |a| normalize_address(a) } if bcc
|
|
52
|
+
body[:reply_to] = normalize_address(reply_to) if reply_to
|
|
53
|
+
body[:headers] = headers if headers
|
|
54
|
+
body[:attachments] = attachments if attachments
|
|
55
|
+
|
|
56
|
+
perform(:send, "/accounts/#{@account_id}/email/sending/send", body)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def send_raw(from:, recipients:, mime_message:)
|
|
60
|
+
body = {
|
|
61
|
+
from: extract_address(from),
|
|
62
|
+
recipients: wrap(recipients).map { |r| extract_address(r) },
|
|
63
|
+
mime_message: mime_message,
|
|
64
|
+
}
|
|
65
|
+
perform(:send_raw, "/accounts/#{@account_id}/email/sending/send_raw", body)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Wrap a value into an array without Hash-to-pair-array conversion.
|
|
71
|
+
def wrap(value)
|
|
72
|
+
case value
|
|
73
|
+
when Array then value
|
|
74
|
+
when nil then []
|
|
75
|
+
else [value]
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def normalize_address(addr)
|
|
80
|
+
case addr
|
|
81
|
+
when String
|
|
82
|
+
addr
|
|
83
|
+
when Hash
|
|
84
|
+
h = { address: addr[:address] || addr["address"] }
|
|
85
|
+
name = addr[:name] || addr["name"]
|
|
86
|
+
h[:name] = name if name
|
|
87
|
+
raise ValidationError, "address hash requires :address" unless h[:address]
|
|
88
|
+
h
|
|
89
|
+
else
|
|
90
|
+
raise ValidationError, "address must be a String or Hash, got #{addr.class}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def extract_address(addr)
|
|
95
|
+
case addr
|
|
96
|
+
when String then addr
|
|
97
|
+
when Hash then addr[:address] || addr["address"] || raise(ValidationError, "address hash requires :address")
|
|
98
|
+
else raise ValidationError, "address must be a String or Hash, got #{addr.class}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def perform(operation, path, body)
|
|
103
|
+
instrument("cloudflare_email.#{operation}", account_id: @account_id, path: path) do |payload|
|
|
104
|
+
response = request(:post, path, body)
|
|
105
|
+
payload[:status] = response.status
|
|
106
|
+
payload[:message_id] = response.message_id
|
|
107
|
+
response
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def instrument(name, payload)
|
|
112
|
+
if defined?(ActiveSupport::Notifications)
|
|
113
|
+
ActiveSupport::Notifications.instrument(name, payload) { |p| yield p }
|
|
114
|
+
else
|
|
115
|
+
yield payload
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def request(method, path, body)
|
|
120
|
+
uri = URI.parse("#{@base_url}#{path}")
|
|
121
|
+
attempts = 0
|
|
122
|
+
backoff = @initial_backoff
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
attempts += 1
|
|
126
|
+
do_request(method, uri, body)
|
|
127
|
+
rescue *RETRYABLE_NETWORK => e
|
|
128
|
+
raise NetworkError.new(e.message) if attempts > @retries
|
|
129
|
+
log_retry(attempts, e)
|
|
130
|
+
sleep(backoff); backoff *= 2
|
|
131
|
+
retry
|
|
132
|
+
rescue RateLimitError => e
|
|
133
|
+
raise if attempts > @retries
|
|
134
|
+
log_retry(attempts, e)
|
|
135
|
+
sleep(retry_after_from(e, backoff)); backoff *= 2
|
|
136
|
+
retry
|
|
137
|
+
rescue ServerError => e
|
|
138
|
+
raise if attempts > @retries
|
|
139
|
+
log_retry(attempts, e)
|
|
140
|
+
sleep(backoff); backoff *= 2
|
|
141
|
+
retry
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def retry_after_from(error, fallback_backoff)
|
|
146
|
+
header = error.response.is_a?(Hash) ? error.response["retry_after"] : nil
|
|
147
|
+
value = header || fallback_backoff
|
|
148
|
+
seconds = value.to_f
|
|
149
|
+
return fallback_backoff if seconds <= 0
|
|
150
|
+
[seconds, @max_retry_after].min
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def do_request(method, uri, body)
|
|
154
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
155
|
+
http.use_ssl = (uri.scheme == "https")
|
|
156
|
+
http.open_timeout = @timeout
|
|
157
|
+
http.read_timeout = @timeout
|
|
158
|
+
|
|
159
|
+
req_class = { post: Net::HTTP::Post, get: Net::HTTP::Get }.fetch(method)
|
|
160
|
+
req = req_class.new(uri.request_uri)
|
|
161
|
+
req["Authorization"] = "Bearer #{@api_token}"
|
|
162
|
+
req["Content-Type"] = "application/json"
|
|
163
|
+
req["Accept"] = "application/json"
|
|
164
|
+
req["User-Agent"] = "cloudflare-email-ruby/#{Cloudflare::Email::VERSION}"
|
|
165
|
+
req.body = JSON.generate(body) if body
|
|
166
|
+
|
|
167
|
+
response = http.request(req)
|
|
168
|
+
handle_response(response)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_response(response)
|
|
172
|
+
status = response.code.to_i
|
|
173
|
+
body = parse_body(response.body)
|
|
174
|
+
retry_after = response["Retry-After"]
|
|
175
|
+
|
|
176
|
+
# Stash Retry-After on the error response so retry logic can use it.
|
|
177
|
+
body = body.merge("retry_after" => retry_after) if body.is_a?(Hash) && retry_after
|
|
178
|
+
|
|
179
|
+
case status
|
|
180
|
+
when 200..299
|
|
181
|
+
Response.new(body, status: status)
|
|
182
|
+
when 400, 422
|
|
183
|
+
raise ValidationError.new(extract_message(body), status: status, response: body)
|
|
184
|
+
when 401, 403
|
|
185
|
+
raise AuthenticationError.new(extract_message(body), status: status, response: body)
|
|
186
|
+
when 429
|
|
187
|
+
raise RateLimitError.new(extract_message(body), status: status, response: body)
|
|
188
|
+
when 500..599
|
|
189
|
+
raise ServerError.new(extract_message(body), status: status, response: body)
|
|
190
|
+
else
|
|
191
|
+
raise Error.new("unexpected status #{status}: #{extract_message(body)}",
|
|
192
|
+
status: status, response: body)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def parse_body(raw)
|
|
197
|
+
return {} if raw.nil? || raw.empty?
|
|
198
|
+
JSON.parse(raw)
|
|
199
|
+
rescue JSON::ParserError
|
|
200
|
+
{ "errors" => [{ "message" => raw.to_s[0, 200] }] }
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def extract_message(body)
|
|
204
|
+
return "unknown error" unless body.is_a?(Hash)
|
|
205
|
+
errors = body["errors"]
|
|
206
|
+
return "unknown error" unless errors.is_a?(Array) && errors.any?
|
|
207
|
+
errors.map { |e| e.is_a?(Hash) ? e["message"] : e.to_s }.compact.join("; ")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def log_retry(attempt, error)
|
|
211
|
+
return unless @logger
|
|
212
|
+
@logger.warn("[cloudflare-email] retry #{attempt}: #{error.class}: #{error.message}")
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
module Cloudflare
|
|
2
|
+
module Email
|
|
3
|
+
# Unified credential lookup.
|
|
4
|
+
#
|
|
5
|
+
# Precedence:
|
|
6
|
+
# 1. Rails.application.credentials.dig(:cloudflare, key) — encrypted credentials.yml.enc
|
|
7
|
+
# (respects per-environment files: config/credentials/{env}.yml.enc when present)
|
|
8
|
+
# 2. ENV["CLOUDFLARE_#{KEY}"] — env vars, including anything dotenv or foreman loaded from .env
|
|
9
|
+
#
|
|
10
|
+
# Supported keys:
|
|
11
|
+
# account_id — Cloudflare account ID
|
|
12
|
+
# api_token — runtime token used by the delivery method (needs Email Sending: Send)
|
|
13
|
+
# management_token — higher-privilege token used by deploy/provision rake tasks
|
|
14
|
+
# (needs Workers Scripts: Edit, Zone: Read, Email Routing: Edit).
|
|
15
|
+
# Falls back to api_token if not set.
|
|
16
|
+
# ingress_secret — HMAC shared secret between Worker and Rails ingress
|
|
17
|
+
module Credentials
|
|
18
|
+
class << self
|
|
19
|
+
def account_id
|
|
20
|
+
fetch(:account_id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def api_token
|
|
24
|
+
fetch(:api_token)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# For deploy_worker, provision_route, and dev tasks. Prefer a
|
|
28
|
+
# dedicated higher-privilege token if the user has split them;
|
|
29
|
+
# otherwise reuse api_token (most single-token setups).
|
|
30
|
+
def management_token
|
|
31
|
+
token = fetch(:management_token)
|
|
32
|
+
token.empty? ? api_token : token
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def ingress_secret
|
|
36
|
+
fetch(:ingress_secret)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# True if the user has set a separate management token.
|
|
40
|
+
def split_tokens?
|
|
41
|
+
!fetch(:management_token).empty?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fetch(key)
|
|
45
|
+
from_rails = rails_credentials_dig(key)
|
|
46
|
+
return from_rails unless from_rails.empty?
|
|
47
|
+
ENV["CLOUDFLARE_#{key.to_s.upcase}"].to_s
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def rails_credentials_dig(key)
|
|
51
|
+
return "" unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
|
|
52
|
+
Rails.application.credentials.dig(:cloudflare, key).to_s
|
|
53
|
+
rescue StandardError
|
|
54
|
+
""
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
require "cloudflare/email/client"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# ActionMailer delivery method. Registered on the :cloudflare symbol by the Engine.
|
|
6
|
+
#
|
|
7
|
+
# Configure in your Rails app:
|
|
8
|
+
#
|
|
9
|
+
# config.action_mailer.delivery_method = :cloudflare
|
|
10
|
+
# config.action_mailer.cloudflare_settings = {
|
|
11
|
+
# account_id: Rails.application.credentials.dig(:cloudflare, :account_id),
|
|
12
|
+
# api_token: Rails.application.credentials.dig(:cloudflare, :api_token),
|
|
13
|
+
# }
|
|
14
|
+
class DeliveryMethod
|
|
15
|
+
attr_accessor :settings
|
|
16
|
+
|
|
17
|
+
def initialize(settings = {})
|
|
18
|
+
@settings = settings
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def deliver!(mail)
|
|
22
|
+
client = Cloudflare::Email::Client.new(
|
|
23
|
+
account_id: settings.fetch(:account_id),
|
|
24
|
+
api_token: settings.fetch(:api_token),
|
|
25
|
+
base_url: settings[:base_url] || Cloudflare::Email::Client::DEFAULT_BASE_URL,
|
|
26
|
+
retries: settings.fetch(:retries, Cloudflare::Email::Client::DEFAULT_RETRIES),
|
|
27
|
+
timeout: settings.fetch(:timeout, Cloudflare::Email::Client::DEFAULT_TIMEOUT),
|
|
28
|
+
logger: settings[:logger],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
from_addr = mail.from && mail.from.first
|
|
32
|
+
recipients = collect_recipients(mail)
|
|
33
|
+
|
|
34
|
+
raise Cloudflare::Email::ValidationError, "mail has no :from address" if from_addr.nil?
|
|
35
|
+
raise Cloudflare::Email::ValidationError, "mail has no recipients" if recipients.empty?
|
|
36
|
+
|
|
37
|
+
response = client.send_raw(
|
|
38
|
+
from: from_addr,
|
|
39
|
+
recipients: recipients,
|
|
40
|
+
mime_message: mail.encoded,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
if response.message_id && mail.respond_to?(:message_id=)
|
|
44
|
+
mail.message_id = response.message_id
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
response
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def collect_recipients(mail)
|
|
53
|
+
[mail.to, mail.cc, mail.bcc].compact.flatten.uniq
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require "cloudflare/email/task_base"
|
|
2
|
+
require "cloudflare/email/worker_deployer"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
# `bin/rails cloudflare:email:deploy_worker` — uploads the Worker
|
|
7
|
+
# script + both secrets via the Cloudflare API. No wrangler required.
|
|
8
|
+
class DeployWorkerTask < TaskBase
|
|
9
|
+
def self.call(script_path: nil, ingress_url: nil, io: $stdout)
|
|
10
|
+
new(io: io, script_path: script_path, ingress_url: ingress_url).call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
protected
|
|
14
|
+
|
|
15
|
+
def run
|
|
16
|
+
require_value!(account_id, "cloudflare.account_id")
|
|
17
|
+
require_value!(management_token, "cloudflare.api_token (or cloudflare.management_token)")
|
|
18
|
+
require_value!(ingress_secret, "cloudflare.ingress_secret — run the installer first")
|
|
19
|
+
|
|
20
|
+
path = script_path
|
|
21
|
+
raise "Worker script not found at #{path} — re-run `bin/rails g cloudflare:email:install`" unless File.exist?(path)
|
|
22
|
+
|
|
23
|
+
deployer = Cloudflare::Email::WorkerDeployer.new(
|
|
24
|
+
account_id: account_id, api_token: management_token,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
say " Deploying Worker '#{deployer.script_name}'..."
|
|
28
|
+
deployer.deploy(script_path: path)
|
|
29
|
+
say " ✓ Worker script deployed"
|
|
30
|
+
|
|
31
|
+
deployer.put_secret("INGRESS_SECRET", ingress_secret)
|
|
32
|
+
say " ✓ INGRESS_SECRET set"
|
|
33
|
+
|
|
34
|
+
if url.to_s.empty?
|
|
35
|
+
say " (skipping RAILS_INGRESS_URL — pass URL=https://... or set RAILS_INGRESS_URL env var)"
|
|
36
|
+
else
|
|
37
|
+
deployer.put_secret("RAILS_INGRESS_URL", url)
|
|
38
|
+
say " ✓ RAILS_INGRESS_URL set to #{url}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
say ""
|
|
42
|
+
say " Next: route an address to Worker '#{deployer.script_name}' — either via"
|
|
43
|
+
say " `bin/rails cloudflare:email:provision_route ADDRESS=...` or in the dashboard."
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def script_path
|
|
49
|
+
opts[:script_path] || default_script_path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def url
|
|
53
|
+
opts[:ingress_url] || ENV["RAILS_INGRESS_URL"].to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def default_script_path
|
|
57
|
+
candidates = ["cloudflare-worker/src/index.js", "cloudflare-worker/src/index.ts"]
|
|
58
|
+
candidates.find { |p| File.exist?(p) } || candidates.first
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
require "cloudflare/email/worker_deployer"
|
|
2
|
+
|
|
3
|
+
module Cloudflare
|
|
4
|
+
module Email
|
|
5
|
+
# `bin/rails cloudflare:email:dev` — spins up a cloudflared tunnel and
|
|
6
|
+
# updates the deployed Worker's RAILS_INGRESS_URL secret so inbound mail
|
|
7
|
+
# can flow through to this local Rails server. No wrangler required.
|
|
8
|
+
class DevTunnel
|
|
9
|
+
INGRESS_PATH = "/rails/action_mailbox/cloudflare/inbound_emails".freeze
|
|
10
|
+
|
|
11
|
+
def self.call(port: 3000, io: $stdout)
|
|
12
|
+
new(port: port, io: io).call
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(port:, io: $stdout)
|
|
16
|
+
@port = port
|
|
17
|
+
@io = io
|
|
18
|
+
@tunnel_pid = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call
|
|
22
|
+
check_prerequisites
|
|
23
|
+
|
|
24
|
+
require "cloudflare/email/credentials"
|
|
25
|
+
deployer = Cloudflare::Email::WorkerDeployer.new(
|
|
26
|
+
account_id: Cloudflare::Email::Credentials.account_id,
|
|
27
|
+
api_token: Cloudflare::Email::Credentials.management_token,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
start_tunnel
|
|
31
|
+
tunnel_url = wait_for_tunnel_url
|
|
32
|
+
@io.puts " Tunnel: #{tunnel_url}"
|
|
33
|
+
|
|
34
|
+
ingress_url = "#{tunnel_url}#{INGRESS_PATH}"
|
|
35
|
+
deployer.put_secret("RAILS_INGRESS_URL", ingress_url)
|
|
36
|
+
@io.puts " Worker '#{deployer.script_name}' RAILS_INGRESS_URL updated → #{ingress_url}"
|
|
37
|
+
@io.puts ""
|
|
38
|
+
@io.puts " Send mail to your routed address; it'll land in this Rails app."
|
|
39
|
+
@io.puts " Ctrl-C to stop."
|
|
40
|
+
@io.puts ""
|
|
41
|
+
|
|
42
|
+
trap("INT") { cleanup; exit 0 }
|
|
43
|
+
trap("TERM") { cleanup; exit 0 }
|
|
44
|
+
|
|
45
|
+
# Stay alive so the tunnel keeps running. The user stops us with Ctrl-C.
|
|
46
|
+
sleep
|
|
47
|
+
ensure
|
|
48
|
+
cleanup
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def check_prerequisites
|
|
54
|
+
unless system("command -v cloudflared >/dev/null 2>&1")
|
|
55
|
+
raise "cloudflared not found in PATH — install from https://developers.cloudflare.com/cloudflared/"
|
|
56
|
+
end
|
|
57
|
+
require "cloudflare/email/credentials"
|
|
58
|
+
if Cloudflare::Email::Credentials.account_id.empty? ||
|
|
59
|
+
Cloudflare::Email::Credentials.management_token.empty?
|
|
60
|
+
raise "Missing cloudflare.account_id or cloudflare.api_token in credentials " \
|
|
61
|
+
"(or CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN env vars)"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def start_tunnel
|
|
66
|
+
@io.puts " Starting cloudflared tunnel on :#{@port}..."
|
|
67
|
+
@tunnel_log = File.open("/tmp/cloudflare-email-dev-tunnel.log", "w")
|
|
68
|
+
@tunnel_pid = spawn(
|
|
69
|
+
"cloudflared", "tunnel", "--url", "http://127.0.0.1:#{@port}",
|
|
70
|
+
out: @tunnel_log, err: @tunnel_log,
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wait_for_tunnel_url
|
|
75
|
+
deadline = Time.now + 30
|
|
76
|
+
while Time.now < deadline
|
|
77
|
+
sleep 0.5
|
|
78
|
+
log = File.read("/tmp/cloudflare-email-dev-tunnel.log") rescue ""
|
|
79
|
+
if (match = log.match(%r{https://[a-z0-9\-]+\.trycloudflare\.com}))
|
|
80
|
+
return match[0]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
raise "Timed out waiting for cloudflared to return a tunnel URL"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def cleanup
|
|
87
|
+
if @tunnel_pid
|
|
88
|
+
Process.kill("TERM", @tunnel_pid) rescue nil
|
|
89
|
+
Process.wait(@tunnel_pid) rescue nil
|
|
90
|
+
@tunnel_pid = nil
|
|
91
|
+
end
|
|
92
|
+
@tunnel_log&.close
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|