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,227 @@
|
|
|
1
|
+
require "rails/generators/base"
|
|
2
|
+
require "securerandom"
|
|
3
|
+
|
|
4
|
+
module Cloudflare
|
|
5
|
+
module Email
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
8
|
+
namespace "cloudflare:email:install"
|
|
9
|
+
|
|
10
|
+
source_root File.expand_path("templates", __dir__)
|
|
11
|
+
|
|
12
|
+
class_option :inbound, type: :boolean, default: true,
|
|
13
|
+
desc: "Set up ActionMailbox ingress + Cloudflare Worker template for inbound mail"
|
|
14
|
+
|
|
15
|
+
class_option :worker_dir, type: :string, default: "cloudflare-worker",
|
|
16
|
+
desc: "Directory to copy the Cloudflare Worker template into"
|
|
17
|
+
|
|
18
|
+
class_option :all_envs, type: :boolean, default: false,
|
|
19
|
+
desc: "Also configure action_mailbox.ingress in development.rb and test.rb (not just production.rb)"
|
|
20
|
+
|
|
21
|
+
class_option :deploy_worker, type: :boolean, default: nil,
|
|
22
|
+
desc: "Deploy the Worker via wrangler after setup (nil = interactive prompt)"
|
|
23
|
+
|
|
24
|
+
class_option :scaffold_mailbox, type: :boolean, default: nil,
|
|
25
|
+
desc: "Scaffold MainMailbox + catch-all route so inbound has somewhere to land (nil = interactive prompt)"
|
|
26
|
+
|
|
27
|
+
def create_initializer
|
|
28
|
+
template "initializer.rb", "config/initializers/cloudflare_email.rb"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def ensure_action_mailbox_installed
|
|
32
|
+
return unless options[:inbound]
|
|
33
|
+
return if File.exist?("app/mailboxes/application_mailbox.rb")
|
|
34
|
+
|
|
35
|
+
say ""
|
|
36
|
+
if yes?("ActionMailbox isn't installed yet. Run `bin/rails action_mailbox:install` now? [Y/n]", :green)
|
|
37
|
+
rails_command "action_mailbox:install", inline: true
|
|
38
|
+
rails_command "db:migrate", inline: true
|
|
39
|
+
else
|
|
40
|
+
say " Skipping — run `bin/rails action_mailbox:install` manually before inbound will work.", :yellow
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def maybe_scaffold_mailbox
|
|
45
|
+
return unless options[:inbound]
|
|
46
|
+
return unless File.exist?("app/mailboxes/application_mailbox.rb")
|
|
47
|
+
|
|
48
|
+
if File.exist?("app/mailboxes/main_mailbox.rb")
|
|
49
|
+
say " app/mailboxes/main_mailbox.rb already exists — skipping.", :cyan
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if active_route?("app/mailboxes/application_mailbox.rb")
|
|
54
|
+
say " ApplicationMailbox already has routes — skipping MainMailbox scaffold.", :cyan
|
|
55
|
+
return
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
should_scaffold = options[:scaffold_mailbox]
|
|
59
|
+
if should_scaffold.nil?
|
|
60
|
+
should_scaffold = yes?(
|
|
61
|
+
"Scaffold a default MainMailbox + catch-all route so inbound has somewhere to land? [Y/n]",
|
|
62
|
+
:green,
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
return unless should_scaffold
|
|
66
|
+
|
|
67
|
+
template "main_mailbox.rb", "app/mailboxes/main_mailbox.rb"
|
|
68
|
+
|
|
69
|
+
inject_into_class "app/mailboxes/application_mailbox.rb", "ApplicationMailbox" do
|
|
70
|
+
" routing :all => :main\n"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def configure_action_mailbox_ingress
|
|
75
|
+
return unless options[:inbound]
|
|
76
|
+
|
|
77
|
+
envs = ["production"]
|
|
78
|
+
envs += ["development", "test"] if options[:all_envs]
|
|
79
|
+
|
|
80
|
+
envs.each do |env|
|
|
81
|
+
file = "config/environments/#{env}.rb"
|
|
82
|
+
next unless File.exist?(file)
|
|
83
|
+
next if File.read(file).include?("action_mailbox.ingress = :cloudflare")
|
|
84
|
+
|
|
85
|
+
inject_into_file file,
|
|
86
|
+
" config.action_mailbox.ingress = :cloudflare\n",
|
|
87
|
+
after: /Rails\.application\.configure do\n/
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def copy_worker_template
|
|
92
|
+
return unless options[:inbound]
|
|
93
|
+
|
|
94
|
+
worker_src = File.expand_path("../../../../templates/worker", __dir__)
|
|
95
|
+
directory worker_src, options[:worker_dir]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def maybe_deploy_worker
|
|
99
|
+
return unless options[:inbound]
|
|
100
|
+
|
|
101
|
+
should_deploy = options[:deploy_worker]
|
|
102
|
+
if should_deploy.nil?
|
|
103
|
+
say ""
|
|
104
|
+
say " The Worker can be deployed via the Cloudflare API (pure Ruby, no wrangler/Node)"
|
|
105
|
+
say " once you've set cloudflare.account_id and cloudflare.api_token in Rails credentials."
|
|
106
|
+
say " Run `bin/rails cloudflare:email:deploy_worker URL=https://yourapp.com#{ingress_path}`"
|
|
107
|
+
say " after `bin/rails credentials:edit`."
|
|
108
|
+
say ""
|
|
109
|
+
say " Alternatively, deploy now via wrangler if it's installed locally." if wrangler_installed?
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if should_deploy && wrangler_installed?
|
|
113
|
+
wrangler_deploy
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def wrangler_deploy
|
|
118
|
+
@ingress_secret = SecureRandom.hex(32)
|
|
119
|
+
|
|
120
|
+
inside options[:worker_dir] do
|
|
121
|
+
run "npm install --legacy-peer-deps", abort_on_failure: true
|
|
122
|
+
|
|
123
|
+
ingress_url = ask("Rails ingress URL? (e.g. https://yourapp.com#{ingress_path})")
|
|
124
|
+
if ingress_url.to_s.strip.empty?
|
|
125
|
+
say " Skipping Worker deploy — no URL supplied. Re-run `wrangler deploy` manually when ready.", :yellow
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
run_with_stdin("wrangler secret put RAILS_INGRESS_URL", ingress_url.strip)
|
|
130
|
+
run_with_stdin("wrangler secret put INGRESS_SECRET", @ingress_secret)
|
|
131
|
+
run "wrangler deploy", abort_on_failure: false
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
@worker_deployed = true
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def print_post_install
|
|
138
|
+
@ingress_secret ||= SecureRandom.hex(32) if options[:inbound]
|
|
139
|
+
|
|
140
|
+
say ""
|
|
141
|
+
say " cloudflare-email installed.", :green
|
|
142
|
+
say ""
|
|
143
|
+
say " Next steps:"
|
|
144
|
+
say ""
|
|
145
|
+
say " 1. Add credentials:"
|
|
146
|
+
say " bin/rails credentials:edit"
|
|
147
|
+
say " cloudflare:"
|
|
148
|
+
say " account_id: <your-account-id>"
|
|
149
|
+
say " api_token: <your-api-token>"
|
|
150
|
+
if options[:inbound]
|
|
151
|
+
say " ingress_secret: #{@ingress_secret}"
|
|
152
|
+
end
|
|
153
|
+
say ""
|
|
154
|
+
say " 2. Verify your setup:"
|
|
155
|
+
say " bin/rails cloudflare:email:doctor"
|
|
156
|
+
say ""
|
|
157
|
+
say " 3. Send a test email:"
|
|
158
|
+
say " TO=you@example.com bin/rails cloudflare:email:send_test"
|
|
159
|
+
say ""
|
|
160
|
+
|
|
161
|
+
if options[:inbound] && !@worker_deployed
|
|
162
|
+
say " 4. Deploy the Worker (pick one):"
|
|
163
|
+
say " # Pure Ruby (recommended — no wrangler/Node required):"
|
|
164
|
+
say " bin/rails cloudflare:email:deploy_worker URL=https://yourapp.com#{ingress_path}"
|
|
165
|
+
say ""
|
|
166
|
+
say " # Or via wrangler if you have it installed:"
|
|
167
|
+
say " cd #{options[:worker_dir]}"
|
|
168
|
+
say " npm install --legacy-peer-deps"
|
|
169
|
+
say " wrangler secret put INGRESS_SECRET # paste #{@ingress_secret[0, 8]}..."
|
|
170
|
+
say " wrangler secret put RAILS_INGRESS_URL # https://yourapp.com#{ingress_path}"
|
|
171
|
+
say " wrangler deploy"
|
|
172
|
+
say ""
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
if options[:inbound]
|
|
176
|
+
say " 5. For local dev (tunnels cloudflared to your Worker):"
|
|
177
|
+
say " bin/rails cloudflare:email:dev"
|
|
178
|
+
say ""
|
|
179
|
+
say " 6. In the Cloudflare dashboard:"
|
|
180
|
+
say " Email Routing -> Routes -> Send to a Worker -> #{worker_name}"
|
|
181
|
+
say ""
|
|
182
|
+
say " Dashboard deep-links:"
|
|
183
|
+
say " API tokens: https://dash.cloudflare.com/profile/api-tokens"
|
|
184
|
+
say " Sending domains: https://dash.cloudflare.com/?to=/:account/email/sending"
|
|
185
|
+
say " Email routing: https://dash.cloudflare.com/?to=/:account/email/routing"
|
|
186
|
+
say ""
|
|
187
|
+
say " Rotation: to rotate the ingress secret, update cloudflare.ingress_secret"
|
|
188
|
+
say " in Rails credentials AND re-run `wrangler secret put INGRESS_SECRET` in"
|
|
189
|
+
say " #{options[:worker_dir]}/ with the new value, then redeploy the Worker."
|
|
190
|
+
say ""
|
|
191
|
+
say " Dev/test: by default only production.rb is wired to :cloudflare ingress."
|
|
192
|
+
say " Re-run with --all-envs to also configure development.rb and test.rb."
|
|
193
|
+
end
|
|
194
|
+
say ""
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
private
|
|
198
|
+
|
|
199
|
+
# True if ApplicationMailbox contains any uncommented `routing` call.
|
|
200
|
+
def active_route?(file)
|
|
201
|
+
return false unless File.exist?(file)
|
|
202
|
+
File.readlines(file).any? { |line| line =~ /^\s*routing\s/ }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def wrangler_installed?
|
|
206
|
+
system("command -v wrangler >/dev/null 2>&1")
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ingress_path
|
|
210
|
+
"/rails/action_mailbox/cloudflare/inbound_emails"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def worker_name
|
|
214
|
+
"cloudflare-email-ingress"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def run_with_stdin(cmd, input)
|
|
218
|
+
require "open3"
|
|
219
|
+
out, err, status = Open3.capture3(cmd, stdin_data: input + "\n")
|
|
220
|
+
unless status.success?
|
|
221
|
+
say " #{cmd} failed: #{err.empty? ? out : err}", :red
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Cloudflare Email configuration.
|
|
4
|
+
#
|
|
5
|
+
# Outbound: ActionMailer delivery method `:cloudflare` is registered automatically.
|
|
6
|
+
# Set settings here or per-environment.
|
|
7
|
+
#
|
|
8
|
+
# Inbound: ActionMailbox ingress is mounted at
|
|
9
|
+
# /rails/action_mailbox/cloudflare/inbound_emails
|
|
10
|
+
# Set `config.action_mailbox.ingress = :cloudflare` (the install generator
|
|
11
|
+
# does this for production by default) and configure cloudflare.ingress_secret
|
|
12
|
+
# in your Rails credentials.
|
|
13
|
+
|
|
14
|
+
Rails.application.configure do
|
|
15
|
+
config.action_mailer.delivery_method = :cloudflare
|
|
16
|
+
config.action_mailer.cloudflare_settings = {
|
|
17
|
+
account_id: Rails.application.credentials.dig(:cloudflare, :account_id),
|
|
18
|
+
api_token: Rails.application.credentials.dig(:cloudflare, :api_token),
|
|
19
|
+
}
|
|
20
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# Scaffolded by `bin/rails cloudflare:email:install` as the default landing
|
|
2
|
+
# place for inbound Cloudflare email. Replace the `process` body with your
|
|
3
|
+
# real handling — parse the subject, match the sender, hand off to a job, etc.
|
|
4
|
+
#
|
|
5
|
+
# See https://guides.rubyonrails.org/action_mailbox_basics.html for the full
|
|
6
|
+
# ActionMailbox API (mail is a Mail::Message, inbound_email is the AR record).
|
|
7
|
+
class MainMailbox < ApplicationMailbox
|
|
8
|
+
def process
|
|
9
|
+
Rails.logger.info(
|
|
10
|
+
"[cloudflare-email] inbound received: " \
|
|
11
|
+
"from=#{mail.from&.first.inspect} " \
|
|
12
|
+
"to=#{Array(mail.to).inspect} " \
|
|
13
|
+
"subject=#{mail.subject.inspect} " \
|
|
14
|
+
"message_id=#{mail.message_id.inspect}"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Example ways to pull content out of the incoming message:
|
|
18
|
+
#
|
|
19
|
+
# mail.from.first # => "alice@example.com"
|
|
20
|
+
# mail.to # => ["cole@in.rebulk.com"]
|
|
21
|
+
# mail.subject # => "Re: agent task"
|
|
22
|
+
# mail.body.decoded # => "text/plain body, decoded"
|
|
23
|
+
# mail.html_part&.body&.decoded
|
|
24
|
+
# mail.attachments # => [Mail::Part, ...]
|
|
25
|
+
# inbound_email.message_id # => "<...@mail.gmail.com>"
|
|
26
|
+
# inbound_email.raw_email.download # full RFC822 bytes
|
|
27
|
+
#
|
|
28
|
+
# Common next steps:
|
|
29
|
+
# YourAgentJob.perform_later(mail.from.first, mail.body.decoded)
|
|
30
|
+
# bounce_with BounceMailer.not_recognized(inbound_email) if ignore?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
namespace :cloudflare do
|
|
2
|
+
namespace :email do
|
|
3
|
+
desc "Run diagnostics against your Cloudflare Email setup"
|
|
4
|
+
task doctor: :environment do
|
|
5
|
+
require "cloudflare/email/doctor"
|
|
6
|
+
exit Cloudflare::Email::Doctor.call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
desc "Send a test email via the current Cloudflare Email config (TO=addr FROM=addr)"
|
|
10
|
+
task send_test: :environment do
|
|
11
|
+
require "cloudflare/email/send_test"
|
|
12
|
+
exit Cloudflare::Email::SendTest.call(to: ENV["TO"], from: ENV["FROM"])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
desc "Deploy the Worker via Cloudflare API (no wrangler/Node required). URL=https://... sets RAILS_INGRESS_URL"
|
|
16
|
+
task deploy_worker: :environment do
|
|
17
|
+
require "cloudflare/email/deploy_worker_task"
|
|
18
|
+
exit Cloudflare::Email::DeployWorkerTask.call(ingress_url: ENV["URL"])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
desc "Create Cloudflare Email Routing rule: ADDRESS=addr@domain → env-scoped Worker (WORKER=name to override)"
|
|
22
|
+
task provision_route: :environment do
|
|
23
|
+
require "cloudflare/email/provision_route_task"
|
|
24
|
+
exit Cloudflare::Email::ProvisionRouteTask.call(
|
|
25
|
+
address: ENV["ADDRESS"],
|
|
26
|
+
worker_name: ENV["WORKER"],
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
desc "Point the zone's catch-all rule at the env-scoped Worker (DOMAIN=in.example.com [WORKER=name])"
|
|
31
|
+
task provision_catchall: :environment do
|
|
32
|
+
require "cloudflare/email/provision_catchall_task"
|
|
33
|
+
exit Cloudflare::Email::ProvisionCatchallTask.call(
|
|
34
|
+
domain: ENV["DOMAIN"],
|
|
35
|
+
worker_name: ENV["WORKER"],
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
desc "Run a cloudflared tunnel pointed at this Rails app, update the Worker's RAILS_INGRESS_URL, and tail logs"
|
|
40
|
+
task dev: :environment do
|
|
41
|
+
require "cloudflare/email/dev_tunnel"
|
|
42
|
+
Cloudflare::Email::DevTunnel.call(port: ENV.fetch("PORT", "3000").to_i)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# cloudflare-email-ingress
|
|
2
|
+
|
|
3
|
+
A Cloudflare Email Worker that forwards inbound mail to a Rails ActionMailbox
|
|
4
|
+
ingress shipped with the [`cloudflare-email`](https://github.com/cole/cloudflare-email)
|
|
5
|
+
gem.
|
|
6
|
+
|
|
7
|
+
## Deploy
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install
|
|
11
|
+
wrangler secret put INGRESS_SECRET # same value as cloudflare.ingress_secret in Rails credentials
|
|
12
|
+
wrangler secret put RAILS_INGRESS_URL # e.g. https://your-app.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
13
|
+
wrangler deploy
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Then in the Cloudflare dashboard:
|
|
17
|
+
|
|
18
|
+
1. **Email Routing → Routes**
|
|
19
|
+
2. Add a route for the address you want to receive on (e.g. `support@yourdomain.com`)
|
|
20
|
+
3. Action: **Send to a Worker** → `cloudflare-email-ingress`
|
|
21
|
+
|
|
22
|
+
## How it works
|
|
23
|
+
|
|
24
|
+
For each inbound message, the Worker:
|
|
25
|
+
|
|
26
|
+
1. Reads the raw RFC822 bytes from `message.raw`.
|
|
27
|
+
2. Computes `HMAC-SHA256(INGRESS_SECRET, "{unix_timestamp}.{raw_body}")`.
|
|
28
|
+
3. POSTs the raw bytes to `RAILS_INGRESS_URL` with:
|
|
29
|
+
- `Content-Type: message/rfc822`
|
|
30
|
+
- `X-CF-Email-Timestamp: <unix seconds>`
|
|
31
|
+
- `X-CF-Email-Signature: <hex digest>`
|
|
32
|
+
4. If the Rails app responds non-2xx, the Worker calls `message.setReject` so
|
|
33
|
+
Cloudflare returns a delivery failure to the sender (the message is not
|
|
34
|
+
silently dropped).
|
|
35
|
+
|
|
36
|
+
The Rails controller verifies the signature in constant time and rejects
|
|
37
|
+
timestamps older than 5 minutes (replay protection).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cloudflare-email-ingress",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"description": "Cloudflare Email Worker that forwards inbound mail to a Rails ActionMailbox ingress (HMAC-signed).",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"deploy": "wrangler deploy",
|
|
8
|
+
"dev": "wrangler dev",
|
|
9
|
+
"test": "vitest run"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"vitest": "^3.2.0",
|
|
13
|
+
"wrangler": "^4.0.0"
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare Email Worker → Rails ActionMailbox ingress.
|
|
3
|
+
*
|
|
4
|
+
* Receives mail via Cloudflare Email Routing, signs the raw RFC822 with
|
|
5
|
+
* HMAC-SHA256 over "{timestamp}.{raw_body}", and POSTs it to the Rails
|
|
6
|
+
* ingress controller shipped with the cloudflare-email gem.
|
|
7
|
+
*
|
|
8
|
+
* Required environment variables (set via `wrangler secret put` OR the
|
|
9
|
+
* `cloudflare:email:deploy_worker` rake task shipped with this gem):
|
|
10
|
+
*
|
|
11
|
+
* RAILS_INGRESS_URL e.g. https://your-app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
12
|
+
* INGRESS_SECRET shared secret (same as cloudflare.ingress_secret in Rails credentials)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
function toHex(buf) {
|
|
16
|
+
const bytes = new Uint8Array(buf);
|
|
17
|
+
let out = "";
|
|
18
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
19
|
+
out += bytes[i].toString(16).padStart(2, "0");
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function sign(secret, data) {
|
|
25
|
+
const key = await crypto.subtle.importKey(
|
|
26
|
+
"raw",
|
|
27
|
+
new TextEncoder().encode(secret),
|
|
28
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
29
|
+
false,
|
|
30
|
+
["sign"],
|
|
31
|
+
);
|
|
32
|
+
const sig = await crypto.subtle.sign("HMAC", key, data);
|
|
33
|
+
return toHex(sig);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export default {
|
|
37
|
+
async email(message, env) {
|
|
38
|
+
if (!env.RAILS_INGRESS_URL || !env.INGRESS_SECRET) {
|
|
39
|
+
message.setReject("worker missing RAILS_INGRESS_URL or INGRESS_SECRET");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const raw = new Uint8Array(await new Response(message.raw).arrayBuffer());
|
|
44
|
+
const ts = Math.floor(Date.now() / 1000).toString();
|
|
45
|
+
|
|
46
|
+
const tsBytes = new TextEncoder().encode(`${ts}.`);
|
|
47
|
+
const signedPayload = new Uint8Array(tsBytes.length + raw.length);
|
|
48
|
+
signedPayload.set(tsBytes, 0);
|
|
49
|
+
signedPayload.set(raw, tsBytes.length);
|
|
50
|
+
|
|
51
|
+
const signature = await sign(env.INGRESS_SECRET, signedPayload);
|
|
52
|
+
|
|
53
|
+
let res;
|
|
54
|
+
try {
|
|
55
|
+
res = await fetch(env.RAILS_INGRESS_URL, {
|
|
56
|
+
method: "POST",
|
|
57
|
+
headers: {
|
|
58
|
+
"Content-Type": "message/rfc822",
|
|
59
|
+
"X-CF-Email-Timestamp": ts,
|
|
60
|
+
"X-CF-Email-Signature": signature,
|
|
61
|
+
},
|
|
62
|
+
body: raw,
|
|
63
|
+
});
|
|
64
|
+
} catch (err) {
|
|
65
|
+
message.setReject(`upstream fetch failed: ${err.message}`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
message.setReject(`upstream returned ${res.status}`);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
};
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import worker from "../src/index.js";
|
|
3
|
+
|
|
4
|
+
// Minimal fake EmailMessage implementing the surface the Worker uses.
|
|
5
|
+
function makeMessage(raw: string) {
|
|
6
|
+
const rawBytes = new TextEncoder().encode(raw);
|
|
7
|
+
const rejects: string[] = [];
|
|
8
|
+
return {
|
|
9
|
+
message: {
|
|
10
|
+
from: "sender@external.test",
|
|
11
|
+
to: "inbox@trial.test",
|
|
12
|
+
raw: new ReadableStream({
|
|
13
|
+
start(c) { c.enqueue(rawBytes); c.close(); },
|
|
14
|
+
}),
|
|
15
|
+
rawSize: rawBytes.byteLength,
|
|
16
|
+
setReject(reason: string) { rejects.push(reason); },
|
|
17
|
+
},
|
|
18
|
+
rejects,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function verifyHmac(secret: string, ts: string, body: ArrayBuffer, hex: string) {
|
|
23
|
+
const enc = new TextEncoder();
|
|
24
|
+
const prefix = enc.encode(`${ts}.`);
|
|
25
|
+
const signed = new Uint8Array(prefix.length + body.byteLength);
|
|
26
|
+
signed.set(prefix, 0);
|
|
27
|
+
signed.set(new Uint8Array(body), prefix.length);
|
|
28
|
+
|
|
29
|
+
const key = await crypto.subtle.importKey(
|
|
30
|
+
"raw",
|
|
31
|
+
enc.encode(secret),
|
|
32
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
33
|
+
false,
|
|
34
|
+
["verify"],
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
38
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
39
|
+
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return crypto.subtle.verify("HMAC", key, bytes, signed);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("cloudflare-email Worker", () => {
|
|
46
|
+
const RAW = "From: a@b.com\r\nTo: c@d.com\r\nSubject: hi\r\n\r\nBody line.\r\n";
|
|
47
|
+
const SECRET = "worker-test-secret-abc123";
|
|
48
|
+
const URL_ = "https://rails.test/rails/action_mailbox/cloudflare/inbound_emails";
|
|
49
|
+
|
|
50
|
+
let fetchSpy: ReturnType<typeof vi.fn>;
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
fetchSpy = vi.fn(async () => new Response("", { status: 200 }));
|
|
54
|
+
globalThis.fetch = fetchSpy as unknown as typeof fetch;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("POSTs raw MIME with HMAC signature and timestamp", async () => {
|
|
62
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
63
|
+
const { message } = makeMessage(RAW);
|
|
64
|
+
|
|
65
|
+
await worker.email(message as any, env);
|
|
66
|
+
|
|
67
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
68
|
+
const [url, opts] = fetchSpy.mock.calls[0];
|
|
69
|
+
expect(url).toBe(URL_);
|
|
70
|
+
expect(opts.method).toBe("POST");
|
|
71
|
+
expect(opts.headers["Content-Type"]).toBe("message/rfc822");
|
|
72
|
+
const ts = opts.headers["X-CF-Email-Timestamp"];
|
|
73
|
+
const sig = opts.headers["X-CF-Email-Signature"];
|
|
74
|
+
expect(ts).toMatch(/^\d+$/);
|
|
75
|
+
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
|
76
|
+
|
|
77
|
+
// Body bytes match the input.
|
|
78
|
+
const sentBytes = opts.body as Uint8Array;
|
|
79
|
+
expect(new TextDecoder().decode(sentBytes)).toBe(RAW);
|
|
80
|
+
|
|
81
|
+
// And the signature verifies against the input.
|
|
82
|
+
const ok = await verifyHmac(SECRET, ts, sentBytes.buffer, sig);
|
|
83
|
+
expect(ok).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rejects the message when upstream returns non-2xx", async () => {
|
|
87
|
+
fetchSpy.mockResolvedValueOnce(new Response("server error", { status: 503 }));
|
|
88
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
89
|
+
const { message, rejects } = makeMessage(RAW);
|
|
90
|
+
|
|
91
|
+
await worker.email(message as any, env);
|
|
92
|
+
|
|
93
|
+
expect(rejects).toEqual(["upstream returned 503"]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("rejects the message when RAILS_INGRESS_URL is missing", async () => {
|
|
97
|
+
const env = { RAILS_INGRESS_URL: "", INGRESS_SECRET: SECRET };
|
|
98
|
+
const { message, rejects } = makeMessage(RAW);
|
|
99
|
+
|
|
100
|
+
await worker.email(message as any, env);
|
|
101
|
+
|
|
102
|
+
expect(rejects[0]).toMatch(/missing RAILS_INGRESS_URL/);
|
|
103
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects the message when INGRESS_SECRET is missing", async () => {
|
|
107
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: "" };
|
|
108
|
+
const { message, rejects } = makeMessage(RAW);
|
|
109
|
+
|
|
110
|
+
await worker.email(message as any, env);
|
|
111
|
+
|
|
112
|
+
expect(rejects[0]).toMatch(/missing .*INGRESS_SECRET/);
|
|
113
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("rejects when the upstream fetch throws", async () => {
|
|
117
|
+
fetchSpy.mockRejectedValueOnce(new Error("DNS fail"));
|
|
118
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
119
|
+
const { message, rejects } = makeMessage(RAW);
|
|
120
|
+
|
|
121
|
+
await worker.email(message as any, env);
|
|
122
|
+
|
|
123
|
+
expect(rejects[0]).toMatch(/upstream fetch failed: DNS fail/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("signature covers tampered bodies differently", async () => {
|
|
127
|
+
// Sanity check: two different bodies produce two different signatures.
|
|
128
|
+
const env = { RAILS_INGRESS_URL: URL_, INGRESS_SECRET: SECRET };
|
|
129
|
+
|
|
130
|
+
await worker.email(makeMessage(RAW).message as any, env);
|
|
131
|
+
const sig1 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
|
|
132
|
+
|
|
133
|
+
fetchSpy.mockClear();
|
|
134
|
+
await worker.email(makeMessage(RAW + "tamper").message as any, env);
|
|
135
|
+
const sig2 = fetchSpy.mock.calls[0][1].headers["X-CF-Email-Signature"];
|
|
136
|
+
|
|
137
|
+
expect(sig1).not.toBe(sig2);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
name = "cloudflare-email-ingress"
|
|
2
|
+
main = "src/index.js"
|
|
3
|
+
compatibility_date = "2026-04-01"
|
|
4
|
+
|
|
5
|
+
# Required secrets (set via `wrangler secret put`):
|
|
6
|
+
# RAILS_INGRESS_URL - https://your-rails-app.example.com/rails/action_mailbox/cloudflare/inbound_emails
|
|
7
|
+
# INGRESS_SECRET - shared secret matching cloudflare.ingress_secret in Rails credentials
|
|
8
|
+
|
|
9
|
+
# After deploying, in the Cloudflare dashboard:
|
|
10
|
+
# Email Routing -> Routes -> "Send to a Worker" -> select cloudflare-email-ingress
|