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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +87 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +521 -0
  5. data/app/controllers/cloudflare/email/ingress_controller.rb +67 -0
  6. data/lib/cloudflare/email/client.rb +216 -0
  7. data/lib/cloudflare/email/credentials.rb +59 -0
  8. data/lib/cloudflare/email/delivery_method.rb +57 -0
  9. data/lib/cloudflare/email/deploy_worker_task.rb +62 -0
  10. data/lib/cloudflare/email/dev_tunnel.rb +97 -0
  11. data/lib/cloudflare/email/doctor.rb +244 -0
  12. data/lib/cloudflare/email/engine.rb +25 -0
  13. data/lib/cloudflare/email/error.rb +20 -0
  14. data/lib/cloudflare/email/provision_catchall_task.rb +36 -0
  15. data/lib/cloudflare/email/provision_route_task.rb +34 -0
  16. data/lib/cloudflare/email/response.rb +61 -0
  17. data/lib/cloudflare/email/routing_provisioner.rb +220 -0
  18. data/lib/cloudflare/email/secure_message_id.rb +89 -0
  19. data/lib/cloudflare/email/send_test.rb +75 -0
  20. data/lib/cloudflare/email/signing.rb +37 -0
  21. data/lib/cloudflare/email/task_base.rb +61 -0
  22. data/lib/cloudflare/email/verification.rb +44 -0
  23. data/lib/cloudflare/email/version.rb +5 -0
  24. data/lib/cloudflare/email/worker_deployer.rb +183 -0
  25. data/lib/cloudflare-email.rb +9 -0
  26. data/lib/generators/cloudflare/email/install_generator.rb +227 -0
  27. data/lib/generators/cloudflare/email/templates/initializer.rb +20 -0
  28. data/lib/generators/cloudflare/email/templates/main_mailbox.rb +32 -0
  29. data/lib/tasks/cloudflare_email.rake +45 -0
  30. data/templates/worker/README.md +37 -0
  31. data/templates/worker/package.json +15 -0
  32. data/templates/worker/src/index.js +73 -0
  33. data/templates/worker/test/index.test.ts +139 -0
  34. data/templates/worker/vitest.config.ts +8 -0
  35. data/templates/worker/wrangler.toml +10 -0
  36. 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,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ environment: "node",
6
+ include: ["test/**/*.test.ts"],
7
+ },
8
+ });
@@ -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