cloudflare-email_service 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cfedd8bad6300247c099669c46ecc795a2fb19faee035a1edf039717836a12b9
4
- data.tar.gz: 44c03575adca32ba9e85ab037f140051fbc933afcaec13581ebcf3326df6d8ac
3
+ metadata.gz: ae0f87f99efee9db13b22ae4cd2560f75878ee141ce0c34797da94dbb387a37c
4
+ data.tar.gz: 23cd36dc8c771b45a649d93d6d9385ab76751755b72ffcd795840280c359a53f
5
5
  SHA512:
6
- metadata.gz: 2c879194227a7591df1568a59857aba63d2d26b153db4ea7f4b0a5d3bb0ae38759be74429343403640e720a40b9a7c3782eeb6d6463f7796cf204980955032f3
7
- data.tar.gz: 527048e68ef2e504228eeeb11df7efb55a8531fd0d11c92cf833b64a1c916e22d9feedb422636881695e4df41fb0ed89ead512a3f586e544ab6c826e1fc34ebe
6
+ metadata.gz: d5d2e2efb6a2fa418400b2b0d4f26558ef3a647f880d89c81ee71ba1a278d7182ccb8c0db68116a9b106284fb0587b8ebac96a6ba0678aa3da61e7ac6b97b910
7
+ data.tar.gz: c1db350ec8e6323eb9b7b9f3b5db198c1cf1801c3f790567d0d53c8f34c29ed7d9e2108a9601d9ad9c5b4ba7d9076bcee627856961cbdfb7ea2725f875484b7d
data/CHANGELOG.md CHANGED
@@ -6,6 +6,27 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.0] - 2026-06-19
10
+
11
+ ### Added
12
+ - The bundled Email Worker template now answers `GET /` with a
13
+ `{ ok, configured }` health check, so the worker URL no longer errors and you
14
+ can confirm its vars are set.
15
+
16
+ ### Changed
17
+ - The bundled Email Worker template reads the ingress URL from a
18
+ `CLOUDFLARE_EMAIL_INGRESS_URL` Worker var instead of a hardcoded URL, so it
19
+ deploys unchanged across apps/environments.
20
+ - The inbound ingress secret is now `config.ingress_secret` on the gem
21
+ configuration (defaulting from `CLOUDFLARE_EMAIL_INGRESS_SECRET`), set in the
22
+ same `configure` block as the other credentials — instead of an ad-hoc env /
23
+ Rails-credentials lookup in the controller.
24
+
25
+ ### Fixed
26
+ - Read the inbound request body via `request.raw_post`, which is consistent
27
+ across Puma, Falcon, and Unicorn and nil-safe. The previous `request.body.read`
28
+ raised on servers (e.g. Falcon) that hand back no body object.
29
+
9
30
  ## [0.1.0] - 2026-06-18
10
31
 
11
32
  ### Added
data/README.md CHANGED
@@ -1,13 +1,15 @@
1
1
  # Cloudflare Email Service
2
2
 
3
- A small Ruby client for sending transactional email through the
4
- [Cloudflare Email Service](https://developers.cloudflare.com/email-service/).
3
+ A small Ruby client for the [Cloudflare Email Service](https://developers.cloudflare.com/email-service/):
4
+ send transactional email from any Ruby app, and — in Rails — receive it through
5
+ Action Mailbox.
5
6
 
6
- Two interchangeable transports: **REST** (default — zero dependencies, just
7
- `net/http`) and **SMTP** (optional, via the [`mail`](https://rubygems.org/gems/mail)
8
- gem). Same `send_email` call either way; pick the transport in configuration.
7
+ Sending uses one of two interchangeable transports: **REST** (the default — zero
8
+ dependencies, just `net/http`) or **SMTP** (optional, via the
9
+ [`mail`](https://rubygems.org/gems/mail) gem). The same `send_email` call works
10
+ for both.
9
11
 
10
- Developed at [Primevise](https://primevise.com).
12
+ Battle-tested at [Rinkta](https://rinkta.com). Developed at [Primevise](https://primevise.com).
11
13
 
12
14
  <a href="https://github.com/elvinaspredkelis/cloudflare-email_service/actions/workflows/ci.yml"><img alt="CI" src="https://github.com/elvinaspredkelis/cloudflare-email_service/actions/workflows/ci.yml/badge.svg"></a>
13
15
  <a href="https://rubygems.org/gems/cloudflare-email_service"><img alt="Gem Version" src="https://img.shields.io/gem/v/cloudflare-email_service?color=10b981&include_prereleases&logo=ruby&logoColor=f43f5e"></a>
@@ -17,19 +19,19 @@ Developed at [Primevise](https://primevise.com).
17
19
 
18
20
  ## Installation
19
21
 
20
- ```
22
+ ```sh
21
23
  bundle add cloudflare-email_service
22
24
  ```
23
25
 
24
26
  Requires Ruby 3.2+. For the SMTP transport, also add the `mail` gem:
25
27
 
26
- ```
28
+ ```sh
27
29
  bundle add mail
28
30
  ```
29
31
 
30
32
  ---
31
33
 
32
- ## Usage
34
+ ## Quick start
33
35
 
34
36
  Configure once with your Cloudflare API token (plus an account id for REST),
35
37
  then send:
@@ -54,28 +56,15 @@ response.success? # => true
54
56
  response.delivered # => ["recipient@example.com"]
55
57
  ```
56
58
 
57
- `Response` also exposes `#queued`, `#permanent_bounces`, `#errors`, `#status`,
58
- and the raw parsed `#body`.
59
-
60
- > [!TIP]
61
- > `from`, `to`, `cc`, `bcc`, and `reply_to` accept a string
62
- > (`"a@x.com"` or `"Display Name <a@x.com>"`), a hash (`{ email:, name: }`), or —
63
- > for `to` / `cc` / `bcc` — an array of either. Add files with
64
- > `attachments: [{ content: Base64.strict_encode64(bytes), filename:, type: }]`
65
- > and arbitrary headers with `headers: { "In-Reply-To" => "<id>" }`.
66
-
67
- > [!CAUTION]
68
- > The total message size (body + attachments) must not exceed **5 MiB**.
69
-
70
- Skip the global config and pass credentials per client when you send from more
71
- than one account:
59
+ Need to send from more than one account? Skip the global config and build a
60
+ client directly:
72
61
 
73
62
  ```ruby
74
63
  Cloudflare::EmailService::Client.new(account_id: "...", api_token: "...") # REST
75
64
  Cloudflare::EmailService::SMTPClient.new(api_token: "...") # SMTP
76
65
  ```
77
66
 
78
- #### Credentials
67
+ ### Credentials
79
68
 
80
69
  Set credentials in `configure` (above) or through the environment:
81
70
 
@@ -89,11 +78,50 @@ no account id.
89
78
 
90
79
  ---
91
80
 
81
+ ## Messages
82
+
83
+ `send_email` accepts these keywords (`from`, `to`, `subject`, and one of
84
+ `html` / `text` are required):
85
+
86
+ | Keyword | Description |
87
+ | ------- | ----------- |
88
+ | `from` | Sender. A string or `{ email:, name: }` hash. |
89
+ | `to` / `cc` / `bcc` | Recipients. A string, a hash, or an array of either. |
90
+ | `reply_to` | Reply-To address (string or hash). |
91
+ | `subject` | Subject line. |
92
+ | `html` / `text` | Body. Provide either or both. |
93
+ | `attachments` | Array of `{ content:, filename:, type:, disposition: }`. |
94
+ | `headers` | Hash of custom headers, e.g. `{ "In-Reply-To" => "<id>" }`. |
95
+
96
+ In an address hash, `:address` aliases `:email`. Attachment `content` is Base64:
97
+
98
+ ```ruby
99
+ Cloudflare::EmailService.send_email(
100
+ from: "reports@yourdomain.com",
101
+ to: "recipient@example.com",
102
+ subject: "Your report",
103
+ text: "See attached.",
104
+ attachments: [
105
+ {
106
+ content: Base64.strict_encode64(File.read("report.pdf")),
107
+ filename: "report.pdf",
108
+ type: "application/pdf",
109
+ disposition: "attachment", # optional
110
+ },
111
+ ],
112
+ )
113
+ ```
114
+
115
+ > [!CAUTION]
116
+ > The total message size (body + attachments) must not exceed **5 MiB**.
117
+
118
+ ---
119
+
92
120
  ## Transports
93
121
 
94
122
  Both transports accept the same `send_email` call and return the same
95
- `Response` — they differ only in how the message reaches Cloudflare. Choose one
96
- with `config.transport`.
123
+ [`Response`](#response) — they differ only in how the message reaches Cloudflare.
124
+ Choose one with `config.transport`.
97
125
 
98
126
  **REST** (`:rest`, the default) posts JSON to the Cloudflare API over HTTPS
99
127
  using only `net/http` from the standard library — no MIME assembly, no gems.
@@ -130,8 +158,8 @@ config.action_mailer.delivery_method = :cloudflare
130
158
  ```
131
159
 
132
160
  Credentials come from `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN` in the
133
- environment. To set them in code (or pick the SMTP transport), add an
134
- initializer:
161
+ environment. To set them in code, choose the SMTP transport, or receive inbound
162
+ mail, use a single initializer:
135
163
 
136
164
  ```ruby
137
165
  # config/initializers/cloudflare_email_service.rb
@@ -139,6 +167,15 @@ Cloudflare::EmailService.configure do |c|
139
167
  c.account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
140
168
  c.api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
141
169
  # c.transport = :smtp # optional; defaults to :rest
170
+
171
+ # Inbound (Action Mailbox) only — must match the Worker's signing secret:
172
+ c.ingress_secret = Rails.application.credentials.dig(:cloudflare, :ingress_secret)
173
+ end
174
+
175
+ # Inbound (Action Mailbox) only — load the :cloudflare ingress. In `to_prepare`
176
+ # so its controller superclass is autoloadable regardless of boot order.
177
+ Rails.application.config.to_prepare do
178
+ require "cloudflare/email_service/action_mailbox"
142
179
  end
143
180
  ```
144
181
 
@@ -161,17 +198,10 @@ so first enable
161
198
  on your domain (it adds the MX/SPF/DKIM records). Then a Worker forwards each
162
199
  message to a `:cloudflare`
163
200
  [Action Mailbox](https://guides.rubyonrails.org/action_mailbox_basics.html)
164
- ingress that ships with the gem. Three steps:
201
+ ingress that ships with the gem. The initializer above loads the ingress and
202
+ sets the signing secret; then:
165
203
 
166
- **1. Require the ingress** — opt-in, so it stays out of send-only apps:
167
-
168
- ```ruby
169
- # config/initializers/cloudflare_email_service.rb
170
- require "cloudflare/email_service/action_mailbox"
171
- ```
172
-
173
- **2. Select it** and set the shared signing secret — either
174
- `CLOUDFLARE_EMAIL_INGRESS_SECRET` or the `cloudflare.ingress_secret` credential:
204
+ **1. Select the ingress:**
175
205
 
176
206
  ```ruby
177
207
  # config/environments/production.rb
@@ -180,21 +210,42 @@ config.action_mailbox.ingress = :cloudflare
180
210
 
181
211
  The route `POST /rails/action_mailbox/cloudflare/inbound_emails` is registered
182
212
  for you, and every request is verified by an HMAC-SHA256 signature with replay
183
- protection.
213
+ protection. The ingress reads the body via `request.raw_post`, so it works under
214
+ any Rack server — Puma, Falcon, or Unicorn.
184
215
 
185
- **3. Deploy an Email Worker** that signs and forwards each message, and bind it
186
- to an Email Routing rule (or catch-all). One ships with the gem — set your app
187
- URL and give it the same `CLOUDFLARE_EMAIL_INGRESS_SECRET`, then deploy:
216
+ **2. Deploy an Email Worker** that signs and forwards each message, and bind it
217
+ to an Email Routing rule (or catch-all). One ships with the gem — deploy it
218
+ unchanged and set two Worker vars: `CLOUDFLARE_EMAIL_INGRESS_URL` (the route
219
+ above) and `CLOUDFLARE_EMAIL_INGRESS_SECRET` (matching the app):
188
220
 
189
221
  - In this repo: [`templates/cloudflare_email_worker.js`](templates/cloudflare_email_worker.js)
190
222
  - From the installed gem: `Cloudflare::EmailService.worker_template_path`
191
223
 
224
+ Visiting the worker's URL returns a `{ ok, configured }` health check — a quick
225
+ way to confirm it's deployed and both vars are set.
226
+
192
227
  > [!NOTE]
193
228
  > The Worker sends `Content-Type: message/rfc822`; the ingress rejects anything
194
229
  > else with `415 Unsupported Media Type`.
195
230
 
196
231
  ---
197
232
 
233
+ ## Response
234
+
235
+ `send_email` returns a `Cloudflare::EmailService::Response`:
236
+
237
+ | Method | Returns |
238
+ | -------------------- | --------------------------------------------- |
239
+ | `#success?` | `true` when Cloudflare accepted the request |
240
+ | `#delivered` | array of accepted recipient addresses |
241
+ | `#queued` | array of queued recipient addresses |
242
+ | `#permanent_bounces` | array of permanently bounced addresses |
243
+ | `#errors` | array of Cloudflare error objects |
244
+ | `#status` | HTTP status code |
245
+ | `#body` | the raw parsed JSON body |
246
+
247
+ ---
248
+
198
249
  ## Errors
199
250
 
200
251
  Non-2xx responses (and unsuccessful payloads) raise a typed error — every one a
@@ -210,19 +261,38 @@ subclass of `Cloudflare::EmailService::Error`:
210
261
  | `ServerError` | HTTP 5xx |
211
262
  | `NetworkError` | connection, timeout, and TLS failures |
212
263
 
213
- API errors also carry `#status` and `#errors` for context.
264
+ The API errors (`AuthenticationError`, `RequestError`, `RateLimitError`,
265
+ `ServerError`) inherit from `APIError` and carry `#status` and `#errors`:
266
+
267
+ ```ruby
268
+ begin
269
+ Cloudflare::EmailService.send_email(from: "a@x.com", to: "b@y.com", subject: "Hi", text: "Hello")
270
+ rescue Cloudflare::EmailService::APIError => e
271
+ e.status # => 403
272
+ e.errors # => [{ "code" => 10000, "message" => "Authentication error" }]
273
+ e.message # => "[10000] Authentication error"
274
+ end
275
+ ```
214
276
 
215
277
  ---
216
278
 
217
279
  ## Development
218
280
 
219
281
  ```sh
282
+ bundle install # install dependencies
220
283
  bundle exec rake test # run the Minitest suite
221
284
  bundle exec rubocop # lint
222
285
  ```
223
286
 
224
287
  ---
225
288
 
289
+ ## Contributing
290
+
291
+ Bug reports and pull requests welcome on
292
+ [GitHub](https://github.com/elvinaspredkelis/cloudflare-email_service).
293
+
294
+ ---
295
+
226
296
  ## License
227
297
 
228
298
  Released under the [MIT License](LICENSE.txt).
@@ -4,9 +4,11 @@ require "cloudflare/email_service"
4
4
  require "cloudflare/email_service/inbound"
5
5
 
6
6
  # Opt-in Action Mailbox ingress: forwards inbound mail from a Cloudflare Email
7
- # Worker into Action Mailbox. Not loaded by the core gem — require it explicitly
8
- # and select it with `config.action_mailbox.ingress = :cloudflare`. The route is
9
- # registered automatically. See the README for setup and the Worker snippet.
7
+ # Worker into Action Mailbox. Not loaded by the core gem — require it from an
8
+ # initializer (inside `Rails.application.config.to_prepare`, so the controller's
9
+ # superclass is autoloadable) and select it with
10
+ # `config.action_mailbox.ingress = :cloudflare`. The route is registered
11
+ # automatically. See the README for setup and the Worker snippet.
10
12
  if defined?(ActionMailbox)
11
13
  module ActionMailbox
12
14
  module Ingresses
@@ -49,18 +51,17 @@ if defined?(ActionMailbox)
49
51
  end
50
52
 
51
53
  # Read once, as binary, so the bytes match exactly what the Worker
52
- # signed and what Action Mailbox stores.
54
+ # signed and what Action Mailbox stores. `raw_post` is ActionDispatch's
55
+ # body reader — consistent across Puma, Falcon, and Unicorn, and nil-safe
56
+ # (`request.body` can be nil/non-rewindable on some servers).
53
57
  def raw_body
54
- @raw_body ||= begin
55
- request.body.rewind if request.body.respond_to?(:rewind)
56
- request.body.read.to_s.b
57
- end
58
+ @raw_body ||= request.raw_post.to_s.b
58
59
  end
59
60
 
60
- # Shared HMAC secret, from the environment or Rails credentials.
61
+ # Shared HMAC secret, from the gem configuration
62
+ # (`config.ingress_secret` / CLOUDFLARE_EMAIL_INGRESS_SECRET).
61
63
  def signing_secret
62
- ENV["CLOUDFLARE_EMAIL_INGRESS_SECRET"] ||
63
- ::Rails.application.credentials.dig(:cloudflare, :ingress_secret).to_s
64
+ ::Cloudflare::EmailService.configuration.ingress_secret.to_s
64
65
  end
65
66
  end
66
67
  end
@@ -29,6 +29,10 @@ module Cloudflare
29
29
  attr_accessor :open_timeout
30
30
  # @return [Integer] read timeout in seconds.
31
31
  attr_accessor :timeout
32
+ # @return [String, nil] HMAC secret used to verify inbound Action Mailbox
33
+ # requests (CLOUDFLARE_EMAIL_INGRESS_SECRET). Must match the value the
34
+ # Cloudflare Email Worker signs with.
35
+ attr_accessor :ingress_secret
32
36
 
33
37
  def initialize
34
38
  @transport = ENV.fetch("CLOUDFLARE_EMAIL_TRANSPORT", "rest").to_sym
@@ -39,6 +43,7 @@ module Cloudflare
39
43
  @smtp_port = Integer(ENV.fetch("CLOUDFLARE_SMTP_PORT", DEFAULT_SMTP_PORT))
40
44
  @open_timeout = DEFAULT_TIMEOUT
41
45
  @timeout = DEFAULT_TIMEOUT
46
+ @ingress_secret = ENV.fetch("CLOUDFLARE_EMAIL_INGRESS_SECRET", nil)
42
47
  end
43
48
  end
44
49
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cloudflare
4
4
  module EmailService
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -1,30 +1,44 @@
1
1
  // Cloudflare Email Worker for cloudflare-email_service inbound (Action Mailbox).
2
2
  //
3
- // Forwards each received message to your Rails app's :cloudflare ingress, signed
4
- // with HMAC-SHA256 over "<timestamp>.<body>" so the ingress can verify it and
5
- // reject replays.
3
+ // - email(): forwards each received message to your Rails app's :cloudflare
4
+ // ingress, signed with HMAC-SHA256 over "<timestamp>.<body>".
5
+ // - fetch(): a GET health check. Email Workers have no HTTP API, so visiting the
6
+ // worker URL would otherwise error; this returns whether the vars are set.
6
7
  //
7
- // Setup:
8
- // 1. Replace the URL below with your app's host.
9
- // 2. Set CLOUDFLARE_EMAIL_INGRESS_SECRET as a Worker secret, matching the
10
- // app's CLOUDFLARE_EMAIL_INGRESS_SECRET (or cloudflare.ingress_secret).
11
- // 3. Bind the Worker to your Email Routing rule and deploy (e.g. wrangler).
8
+ // Set two Worker secrets/vars, then bind the Worker to an Email Routing rule and
9
+ // deploy (e.g. wrangler):
10
+ // - CLOUDFLARE_EMAIL_INGRESS_URL full URL of the ingress, e.g.
11
+ // https://your-app.example.com/rails/action_mailbox/cloudflare/inbound_emails
12
+ // - CLOUDFLARE_EMAIL_INGRESS_SECRET same value as the app's
13
+ // config.ingress_secret (CLOUDFLARE_EMAIL_INGRESS_SECRET)
12
14
 
13
15
  export default {
16
+ async fetch(_request, env) {
17
+ const configured = Boolean(
18
+ env.CLOUDFLARE_EMAIL_INGRESS_URL && env.CLOUDFLARE_EMAIL_INGRESS_SECRET,
19
+ );
20
+ return Response.json({ ok: true, configured });
21
+ },
22
+
14
23
  async email(message, env) {
24
+ const { CLOUDFLARE_EMAIL_INGRESS_URL: url, CLOUDFLARE_EMAIL_INGRESS_SECRET: secret } = env;
25
+ if (!url || !secret) {
26
+ return message.setReject("CLOUDFLARE_EMAIL_INGRESS_URL / _SECRET not configured");
27
+ }
28
+
15
29
  // arrayBuffer (not text) preserves the raw bytes of non-UTF-8 messages.
16
30
  const raw = new Uint8Array(await new Response(message.raw).arrayBuffer());
17
31
  const ts = Math.floor(Date.now() / 1000).toString();
18
32
 
19
33
  const key = await crypto.subtle.importKey(
20
- "raw", new TextEncoder().encode(env.CLOUDFLARE_EMAIL_INGRESS_SECRET),
34
+ "raw", new TextEncoder().encode(secret),
21
35
  { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
22
36
  );
23
37
  const signed = new Uint8Array([...new TextEncoder().encode(ts + "."), ...raw]);
24
38
  const digest = await crypto.subtle.sign("HMAC", key, signed);
25
39
  const sig = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
26
40
 
27
- const response = await fetch("https://your-app.example.com/rails/action_mailbox/cloudflare/inbound_emails", {
41
+ const response = await fetch(url, {
28
42
  method: "POST",
29
43
  headers: {
30
44
  "Content-Type": "message/rfc822",
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: cloudflare-email_service
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Elvinas Predkelis