cloudflare-email_service 0.0.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e223425c0900fe465d1f698fa9a1cd8ae030e2e48c2c658216a0a2a87605ec12
4
- data.tar.gz: ec9b3749cccfd800c335e16ec9b1c044d97fd993524abd44f578686cff03eaba
3
+ metadata.gz: cfedd8bad6300247c099669c46ecc795a2fb19faee035a1edf039717836a12b9
4
+ data.tar.gz: 44c03575adca32ba9e85ab037f140051fbc933afcaec13581ebcf3326df6d8ac
5
5
  SHA512:
6
- metadata.gz: 76f656827c724a23ae557cb71a375eeb1cf4a85497a2d260733c7f775a7d520375fc80fbb3f19db7ec24374dd8aca018b4d3a4ddeef838048a563f1118a741e3
7
- data.tar.gz: ee6b18139c3eef6372ec19a916317b76819d6ea2ee5d605aaa727d784ce2fe0abfea5944f618b369398af4c47eabc06b360727d2edd1d0178eb7f5175289ee3c
6
+ metadata.gz: 2c879194227a7591df1568a59857aba63d2d26b153db4ea7f4b0a5d3bb0ae38759be74429343403640e720a40b9a7c3782eeb6d6463f7796cf204980955032f3
7
+ data.tar.gz: 527048e68ef2e504228eeeb11df7efb55a8531fd0d11c92cf833b64a1c916e22d9feedb422636881695e4df41fb0ed89ead512a3f586e544ab6c826e1fc34ebe
data/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ All notable changes to this project are documented here. The format is based on
4
4
  [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-06-18
10
+
11
+ ### Added
12
+ - Optional, opt-in Action Mailbox ingress
13
+ (`require "cloudflare/email_service/action_mailbox"`) registering a
14
+ `:cloudflare` ingress at `POST /rails/action_mailbox/cloudflare/inbound_emails`.
15
+ A Cloudflare Email Worker forwards the raw RFC822 message, signed with
16
+ HMAC-SHA256 over `"<timestamp>.<body>"`; the ingress verifies the signature
17
+ (`CLOUDFLARE_EMAIL_INGRESS_SECRET` or the `cloudflare.ingress_secret`
18
+ credential) and rejects stale timestamps to block replays. The core gem stays
19
+ Rails-free; nothing loads unless the ingress is required.
20
+ - A ready-to-deploy Cloudflare Email Worker ships with the gem at
21
+ `templates/cloudflare_email_worker.js`; find it locally via
22
+ `Cloudflare::EmailService.worker_template_path`.
23
+
24
+ ### Changed
25
+ - Require Ruby 3.2+ (Ruby 3.1 is end-of-life and the Rails 8 integration needs
26
+ 3.2.2+).
27
+ - The `:cloudflare` ActionMailer delivery method now registers automatically
28
+ via a Railtie inside Rails — no `require "cloudflare/email_service/rails"`
29
+ needed. Set `config.action_mailer.delivery_method = :cloudflare` and go;
30
+ credentials are read from `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN`.
31
+
32
+ ### Fixed
33
+ - `NetworkError` now also wraps TLS/SSL handshake failures
34
+ (`OpenSSL::SSL::SSLError`) on both transports, matching the documented error
35
+ contract.
36
+
7
37
  ## [0.0.1] - 2026-06-17
8
38
 
9
39
  ### Added
data/README.md CHANGED
@@ -1,97 +1,38 @@
1
- # cloudflare-email_service
2
-
3
- [![CI](https://github.com/elvinaspredkelis/cloudflare-email_service/actions/workflows/ci.yml/badge.svg)](https://github.com/elvinaspredkelis/cloudflare-email_service/actions/workflows/ci.yml)
1
+ # Cloudflare Email Service
4
2
 
5
3
  A small Ruby client for sending transactional email through the
6
- [Cloudflare Email Service](https://developers.cloudflare.com/email-service/),
7
- over either of two transports:
4
+ [Cloudflare Email Service](https://developers.cloudflare.com/email-service/).
8
5
 
9
- - **REST** (default)talks to the send endpoint directly with the Ruby
10
- standard library (`net/http`). **Zero dependencies.** No Rails, no HTTP gems.
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.
11
9
 
12
- ```
13
- POST https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send
14
- ```
10
+ Developed at [Primevise](https://primevise.com).
15
11
 
16
- - **SMTP** — submits over `smtp.mx.cloudflare.net:465` (implicit TLS). MIME is
17
- built with the [`mail`](https://rubygems.org/gems/mail) gem, which is an
18
- **optional** dependency loaded only when you actually use SMTP.
12
+ <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
+ <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>
14
+ <a href="https://rubygems.org/gems/cloudflare-email_service"><img alt="Gem Downloads" src="https://img.shields.io/gem/dt/cloudflare-email_service?color=10b981&include_prereleases&logo=ruby&logoColor=f43f5e"></a>
19
15
 
20
- Same `send_email` call either way — pick the transport in configuration.
16
+ ---
21
17
 
22
18
  ## Installation
23
19
 
24
- Add it to your `Gemfile`:
25
-
26
- ```ruby
27
- gem "cloudflare-email_service"
28
-
29
- # Only if you use the SMTP transport:
30
- gem "mail"
31
20
  ```
32
-
33
- Then run `bundle install`. Or install directly:
34
-
35
- ```sh
36
- gem install cloudflare-email_service
21
+ bundle add cloudflare-email_service
37
22
  ```
38
23
 
39
- Requires Ruby 3.1+.
40
-
41
- ## Credentials
42
-
43
- You need a Cloudflare **API token**, plus an **account id** for the REST
44
- transport. Token scope depends on the transport:
45
-
46
- | Transport | account id | API token scope |
47
- | --------- | ---------- | ---------------------- |
48
- | REST | required | `Email Sending: Send` |
49
- | SMTP | not used | `Email Sending: Edit` |
50
-
51
- Provide them through the environment:
24
+ Requires Ruby 3.2+. For the SMTP transport, also add the `mail` gem:
52
25
 
53
- ```sh
54
- export CLOUDFLARE_ACCOUNT_ID="your-account-id" # REST only
55
- export CLOUDFLARE_API_TOKEN="your-api-token"
56
- export CLOUDFLARE_EMAIL_TRANSPORT="rest" # or "smtp" (default: rest)
57
26
  ```
58
-
59
- or explicitly in code (see below).
60
-
61
- ## Choosing a transport
62
-
63
- The transport is selected once, in configuration; everything else — the
64
- `send_email` call, the returned `Response`, the error classes — is identical.
65
-
66
- ```ruby
67
- # REST (default) — zero dependencies
68
- Cloudflare::EmailService.configure do |config|
69
- config.transport = :rest
70
- config.account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
71
- config.api_token = ENV["CLOUDFLARE_API_TOKEN"]
72
- end
73
-
74
- # SMTP — requires the `mail` gem
75
- Cloudflare::EmailService.configure do |config|
76
- config.transport = :smtp
77
- config.api_token = ENV["CLOUDFLARE_API_TOKEN"] # account_id not needed
78
- end
27
+ bundle add mail
79
28
  ```
80
29
 
81
- SMTP defaults to `smtp.mx.cloudflare.net:465` (implicit TLS); override with
82
- `config.smtp_host` / `config.smtp_port` if needed. If you select `:smtp` without
83
- the `mail` gem installed, a `ConfigurationError` is raised telling you to add it.
84
-
85
- You can also build a transport client directly:
86
-
87
- ```ruby
88
- Cloudflare::EmailService::Client.new(account_id: "...", api_token: "...") # REST
89
- Cloudflare::EmailService::SMTPClient.new(api_token: "...") # SMTP
90
- ```
30
+ ---
91
31
 
92
32
  ## Usage
93
33
 
94
- ### Global configuration
34
+ Configure once with your Cloudflare API token (plus an account id for REST),
35
+ then send:
95
36
 
96
37
  ```ruby
97
38
  require "cloudflare/email_service"
@@ -99,7 +40,6 @@ require "cloudflare/email_service"
99
40
  Cloudflare::EmailService.configure do |config|
100
41
  config.account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
101
42
  config.api_token = ENV["CLOUDFLARE_API_TOKEN"]
102
- config.timeout = 30 # seconds (optional)
103
43
  end
104
44
 
105
45
  response = Cloudflare::EmailService.send_email(
@@ -114,91 +54,87 @@ response.success? # => true
114
54
  response.delivered # => ["recipient@example.com"]
115
55
  ```
116
56
 
117
- ### Explicit client
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>" }`.
118
66
 
119
- Skip the global configuration and pass credentials per client — handy when you
120
- send from more than one account:
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:
121
72
 
122
73
  ```ruby
123
- client = Cloudflare::EmailService::Client.new(
124
- account_id: ENV["CLOUDFLARE_ACCOUNT_ID"],
125
- api_token: ENV["CLOUDFLARE_API_TOKEN"],
126
- )
74
+ Cloudflare::EmailService::Client.new(account_id: "...", api_token: "...") # REST
75
+ Cloudflare::EmailService::SMTPClient.new(api_token: "...") # SMTP
76
+ ```
127
77
 
128
- client.send_email(
129
- from: "welcome@yourdomain.com",
130
- to: "recipient@example.com",
131
- subject: "Welcome!",
132
- text: "Thanks for signing up.",
133
- )
78
+ #### Credentials
79
+
80
+ Set credentials in `configure` (above) or through the environment:
81
+
82
+ ```sh
83
+ export CLOUDFLARE_ACCOUNT_ID="your-account-id" # REST only
84
+ export CLOUDFLARE_API_TOKEN="your-api-token"
134
85
  ```
135
86
 
136
- ### Addresses
87
+ REST needs an `Email Sending: Send` token; SMTP needs `Email Sending: Edit` and
88
+ no account id.
137
89
 
138
- `from`, `to`, `cc`, `bcc`, and `reply_to` accept:
90
+ ---
139
91
 
140
- - a plain string — `"user@example.com"` or `"Display Name <user@example.com>"`
141
- - a hash — `{ email: "user@example.com", name: "Display Name" }`
142
- (`:address` is accepted as an alias for `:email`)
143
- - `to` / `cc` / `bcc` also accept an array of any of the above
92
+ ## Transports
144
93
 
145
- ```ruby
146
- Cloudflare::EmailService.send_email(
147
- from: { email: "welcome@yourdomain.com", name: "Acme" },
148
- to: ["a@example.com", { email: "b@example.com", name: "B" }],
149
- cc: "team@yourdomain.com",
150
- reply_to: "support@yourdomain.com",
151
- subject: "Hi",
152
- text: "Hello",
153
- )
154
- ```
94
+ 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`.
155
97
 
156
- ### Attachments
98
+ **REST** (`:rest`, the default) posts JSON to the Cloudflare API over HTTPS
99
+ using only `net/http` from the standard library — no MIME assembly, no gems.
100
+ Needs an `account_id` and an `Email Sending: Send` token. The right default for
101
+ almost everything.
157
102
 
158
- Attachments are base64-encoded. The total message size (body + attachments)
159
- must not exceed **5 MiB**.
103
+ **SMTP** (`:smtp`) submits over `smtp.mx.cloudflare.net:465` (implicit TLS), with
104
+ MIME built by the [`mail`](https://rubygems.org/gems/mail) gem — loaded lazily,
105
+ only when this transport is used. Needs an `Email Sending: Edit` token and no
106
+ account id. Reach for it when your environment already speaks SMTP or only
107
+ allows SMTP egress.
160
108
 
161
109
  ```ruby
162
- require "base64"
163
-
164
- Cloudflare::EmailService.send_email(
165
- from: "reports@yourdomain.com",
166
- to: "recipient@example.com",
167
- subject: "Your report",
168
- text: "See attached.",
169
- attachments: [
170
- {
171
- content: Base64.strict_encode64(File.read("report.pdf")),
172
- filename: "report.pdf",
173
- type: "application/pdf",
174
- disposition: "attachment", # optional
175
- },
176
- ],
177
- )
110
+ Cloudflare::EmailService.configure do |config|
111
+ config.transport = :smtp # default: :rest
112
+ config.api_token = ENV["CLOUDFLARE_API_TOKEN"]
113
+ end
178
114
  ```
179
115
 
180
- ### Custom headers
116
+ > [!NOTE]
117
+ > Selecting `:smtp` without the `mail` gem installed raises a
118
+ > `ConfigurationError` telling you to add it.
119
+
120
+ ---
121
+
122
+ ## Rails
123
+
124
+ Adding the gem registers a `:cloudflare` delivery method automatically — just
125
+ point ActionMailer at it. No require, no initializer:
181
126
 
182
127
  ```ruby
183
- Cloudflare::EmailService.send_email(
184
- from: "a@yourdomain.com",
185
- to: "b@example.com",
186
- subject: "Re: thread",
187
- text: "Reply body",
188
- headers: { "In-Reply-To" => "<msg-123@yourdomain.com>" },
189
- )
128
+ # config/environments/production.rb
129
+ config.action_mailer.delivery_method = :cloudflare
190
130
  ```
191
131
 
192
- ## Rails / ActionMailer (optional)
193
-
194
- The core gem is Rails-agnostic. Rails integration is **opt-in** and loaded only
195
- when you require it — it registers a `:cloudflare` ActionMailer delivery method
196
- backed by whichever transport you configured.
132
+ 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:
197
135
 
198
136
  ```ruby
199
137
  # config/initializers/cloudflare_email_service.rb
200
- require "cloudflare/email_service/rails"
201
-
202
138
  Cloudflare::EmailService.configure do |c|
203
139
  c.account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
204
140
  c.api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
@@ -206,80 +142,87 @@ Cloudflare::EmailService.configure do |c|
206
142
  end
207
143
  ```
208
144
 
145
+ Your mailers then send through Cloudflare unchanged. Prefer ActionMailer's
146
+ built-in `:smtp` delivery? Point it at Cloudflare with the settings helper:
147
+
209
148
  ```ruby
210
- # config/environments/production.rb
211
- config.action_mailer.delivery_method = :cloudflare
149
+ config.action_mailer.delivery_method = :smtp
150
+ config.action_mailer.smtp_settings = Cloudflare::EmailService.smtp_settings(
151
+ api_token: Rails.application.credentials.dig(:cloudflare, :api_token),
152
+ )
212
153
  ```
213
154
 
214
- Your mailers then send through Cloudflare unchanged:
155
+ ### Inbound email (Action Mailbox)
156
+
157
+ Receive mail too. Cloudflare delivers inbound mail to an app only through an
158
+ [Email Worker](https://developers.cloudflare.com/email-routing/email-workers/),
159
+ so first enable
160
+ [Email Routing](https://developers.cloudflare.com/email-service/get-started/route-emails/)
161
+ on your domain (it adds the MX/SPF/DKIM records). Then a Worker forwards each
162
+ message to a `:cloudflare`
163
+ [Action Mailbox](https://guides.rubyonrails.org/action_mailbox_basics.html)
164
+ ingress that ships with the gem. Three steps:
165
+
166
+ **1. Require the ingress** — opt-in, so it stays out of send-only apps:
215
167
 
216
168
  ```ruby
217
- class WelcomeMailer < ApplicationMailer
218
- def welcome(user)
219
- mail(from: "welcome@yourdomain.com", to: user.email, subject: "Welcome")
220
- end
221
- end
169
+ # config/initializers/cloudflare_email_service.rb
170
+ require "cloudflare/email_service/action_mailbox"
222
171
  ```
223
172
 
224
- Prefer ActionMailer's built-in SMTP delivery instead? Point it at Cloudflare
225
- with the provided settings helper — no adapter required:
173
+ **2. Select it** and set the shared signing secret — either
174
+ `CLOUDFLARE_EMAIL_INGRESS_SECRET` or the `cloudflare.ingress_secret` credential:
226
175
 
227
176
  ```ruby
228
- config.action_mailer.delivery_method = :smtp
229
- config.action_mailer.smtp_settings = Cloudflare::EmailService.smtp_settings(
230
- api_token: Rails.application.credentials.dig(:cloudflare, :api_token),
231
- )
177
+ # config/environments/production.rb
178
+ config.action_mailbox.ingress = :cloudflare
232
179
  ```
233
180
 
234
- ## Response
181
+ The route `POST /rails/action_mailbox/cloudflare/inbound_emails` is registered
182
+ for you, and every request is verified by an HMAC-SHA256 signature with replay
183
+ protection.
184
+
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:
235
188
 
236
- `send_email` returns a `Cloudflare::EmailService::Response`:
189
+ - In this repo: [`templates/cloudflare_email_worker.js`](templates/cloudflare_email_worker.js)
190
+ - From the installed gem: `Cloudflare::EmailService.worker_template_path`
237
191
 
238
- | Method | Returns |
239
- | --------------------- | -------------------------------------------------- |
240
- | `#success?` | `true` when Cloudflare accepted the request |
241
- | `#delivered` | array of accepted recipient addresses |
242
- | `#queued` | array of queued recipient addresses |
243
- | `#permanent_bounces` | array of permanently bounced addresses |
244
- | `#errors` | array of Cloudflare error objects |
245
- | `#status` | the HTTP status code |
246
- | `#body` | the raw parsed JSON body |
192
+ > [!NOTE]
193
+ > The Worker sends `Content-Type: message/rfc822`; the ingress rejects anything
194
+ > else with `415 Unsupported Media Type`.
195
+
196
+ ---
247
197
 
248
198
  ## Errors
249
199
 
250
- Non-2xx responses (and unsuccessful payloads) raise a typed error. All inherit
251
- from `Cloudflare::EmailService::Error`:
200
+ Non-2xx responses (and unsuccessful payloads) raise a typed error every one a
201
+ subclass of `Cloudflare::EmailService::Error`:
252
202
 
253
- | Class | When |
254
- | ---------------------- | --------------------------------------------- |
255
- | `ConfigurationError` | missing `account_id` / `api_token` |
256
- | `ValidationError` | the message is missing required fields |
257
- | `AuthenticationError` | HTTP 401 / 403 |
258
- | `RequestError` | HTTP 400 / 422 and other 4xx |
259
- | `RateLimitError` | HTTP 429 |
260
- | `ServerError` | HTTP 5xx |
261
- | `NetworkError` | connection failures and timeouts |
203
+ | Class | When |
204
+ | --------------------- | ------------------------------------------ |
205
+ | `ConfigurationError` | missing `account_id` / `api_token` |
206
+ | `ValidationError` | the message is missing required fields |
207
+ | `AuthenticationError` | HTTP 401 / 403 |
208
+ | `RequestError` | HTTP 400 / 422 and other 4xx |
209
+ | `RateLimitError` | HTTP 429 |
210
+ | `ServerError` | HTTP 5xx |
211
+ | `NetworkError` | connection, timeout, and TLS failures |
262
212
 
263
- API errors carry extra context:
213
+ API errors also carry `#status` and `#errors` for context.
264
214
 
265
- ```ruby
266
- begin
267
- Cloudflare::EmailService.send_email(...)
268
- rescue Cloudflare::EmailService::APIError => e
269
- e.status # => 403
270
- e.errors # => [{ "code" => 10000, "message" => "Authentication error" }]
271
- e.message # => "[10000] Authentication error"
272
- end
273
- ```
215
+ ---
274
216
 
275
217
  ## Development
276
218
 
277
219
  ```sh
278
- bundle install
279
220
  bundle exec rake test # run the Minitest suite
280
221
  bundle exec rubocop # lint
281
222
  ```
282
223
 
224
+ ---
225
+
283
226
  ## License
284
227
 
285
228
  Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloudflare/email_service"
4
+ require "cloudflare/email_service/inbound"
5
+
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.
10
+ if defined?(ActionMailbox)
11
+ module ActionMailbox
12
+ module Ingresses
13
+ module Cloudflare
14
+ # Receives the raw RFC822 message a Cloudflare Email Worker forwards and
15
+ # hands it to Action Mailbox for routing. The Worker signs each request
16
+ # (HMAC-SHA256 over "<timestamp>.<body>"); we verify the signature and
17
+ # reject stale timestamps before accepting the message.
18
+ class InboundEmailsController < ActionMailbox::BaseController
19
+ before_action :verify_signature, :require_valid_rfc822_message
20
+
21
+ def create
22
+ if raw_body.empty?
23
+ head :unprocessable_entity
24
+ else
25
+ ActionMailbox::InboundEmail.create_and_extract_message_id!(raw_body)
26
+ head :no_content
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def verify_signature
33
+ case ::Cloudflare::EmailService::Inbound.verify(
34
+ secret: signing_secret,
35
+ timestamp: request.headers["X-CF-Email-Timestamp"],
36
+ signature: request.headers["X-CF-Email-Signature"],
37
+ body: raw_body,
38
+ )
39
+ when :ok then nil
40
+ when :stale then head :request_timeout
41
+ else head :unauthorized
42
+ end
43
+ end
44
+
45
+ def require_valid_rfc822_message
46
+ return if request.media_type == "message/rfc822"
47
+
48
+ head :unsupported_media_type
49
+ end
50
+
51
+ # Read once, as binary, so the bytes match exactly what the Worker
52
+ # signed and what Action Mailbox stores.
53
+ 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
+ end
59
+
60
+ # Shared HMAC secret, from the environment or Rails credentials.
61
+ def signing_secret
62
+ ENV["CLOUDFLARE_EMAIL_INGRESS_SECRET"] ||
63
+ ::Rails.application.credentials.dig(:cloudflare, :ingress_secret).to_s
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ # Register the route so it works like the built-in ingresses. Guarded for
71
+ # non-Rails requires; add the route by hand if your boot order skips this.
72
+ if defined?(Rails) && Rails.respond_to?(:application) && Rails.application
73
+ Rails.application.routes.append do
74
+ post "/rails/action_mailbox/cloudflare/inbound_emails" =>
75
+ "action_mailbox/ingresses/cloudflare/inbound_emails#create",
76
+ as: :rails_cloudflare_inbound_emails
77
+ end
78
+ end
79
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "net/http"
4
+ require "openssl"
4
5
  require "json"
5
6
  require "uri"
6
7
 
@@ -32,8 +33,8 @@ module Cloudflare
32
33
  # Builds and sends a message. Accepts the same keyword arguments as
33
34
  # {Message#initialize}.
34
35
  # @return [Response]
35
- def send_email(**kwargs)
36
- deliver(Message.new(**kwargs))
36
+ def send_email(**)
37
+ deliver(Message.new(**))
37
38
  end
38
39
 
39
40
  # Sends a pre-built {Message}.
@@ -58,7 +59,8 @@ module Cloudflare
58
59
 
59
60
  handle(http(uri).request(request))
60
61
  rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET,
61
- Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError => e
62
+ Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError,
63
+ OpenSSL::SSL::SSLError => e
62
64
  raise NetworkError, "request failed: #{e.class}: #{e.message}"
63
65
  end
64
66
 
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+
5
+ module Cloudflare
6
+ module EmailService
7
+ # Verifies the HMAC-SHA256 signature a Cloudflare Email Worker attaches to a
8
+ # forwarded inbound message. The Worker signs `"<timestamp>.<raw body>"` with
9
+ # a shared secret and sends the timestamp and hex digest as headers; this
10
+ # recomputes the digest, compares it in constant time, and rejects stale
11
+ # timestamps to block replays.
12
+ #
13
+ # Pure and Rails-free (stdlib OpenSSL only) so it can be unit-tested on its
14
+ # own; the Action Mailbox ingress is a thin wrapper around {.verify}.
15
+ module Inbound
16
+ module_function
17
+
18
+ # Reject timestamps more than this many seconds from now (either side).
19
+ REPLAY_WINDOW = 300
20
+
21
+ # @return [Symbol] :ok, :stale (timestamp outside the window), or
22
+ # :bad_signature (missing/empty input or digest mismatch).
23
+ def verify(secret:, timestamp:, signature:, body:, now: Time.now.to_i)
24
+ return :bad_signature if [secret, timestamp, signature, body].any? { |v| v.to_s.empty? }
25
+ return :stale if (now - timestamp.to_i).abs > REPLAY_WINDOW
26
+
27
+ # Build the signed payload in binary: raw RFC822 bodies carry bytes > 127
28
+ # (8bit transfer encoding, binary attachments), which would raise
29
+ # Encoding::CompatibilityError if interpolated into a UTF-8 string.
30
+ signed = "#{timestamp}.".b + body.to_s.b
31
+ expected = OpenSSL::HMAC.hexdigest("SHA256", secret.to_s, signed)
32
+ secure_compare(expected, signature.to_s) ? :ok : :bad_signature
33
+ end
34
+
35
+ # Constant-time string comparison. Bails early on a length mismatch, which
36
+ # the digest's fixed width makes safe to leak.
37
+ def secure_compare(expected, actual)
38
+ return false unless expected.bytesize == actual.bytesize
39
+
40
+ OpenSSL.fixed_length_secure_compare(expected, actual)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -4,22 +4,21 @@ require "cloudflare/email_service"
4
4
 
5
5
  module Cloudflare
6
6
  module EmailService
7
- # Opt-in Rails / ActionMailer integration.
7
+ # Rails / ActionMailer integration. Registers a `:cloudflare` delivery
8
+ # method backed by the configured transport.
8
9
  #
9
- # This file is NOT loaded by the core gemrequire it explicitly (e.g. from
10
- # an initializer) to register a `:cloudflare` ActionMailer delivery method
11
- # backed by the configured transport:
10
+ # Inside a Rails app this loads automatically via {Railtie}just set the
11
+ # delivery method and credentials; no require needed:
12
12
  #
13
- # # config/initializers/cloudflare_email_service.rb
14
- # require "cloudflare/email_service/rails"
13
+ # # config/environments/production.rb
14
+ # config.action_mailer.delivery_method = :cloudflare
15
15
  #
16
+ # # credentials come from ENV (CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN)
17
+ # # or an initializer:
16
18
  # Cloudflare::EmailService.configure do |c|
17
19
  # c.account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
18
20
  # c.api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
19
21
  # end
20
- #
21
- # # config/environments/production.rb
22
- # config.action_mailer.delivery_method = :cloudflare
23
22
  module Rails
24
23
  # Converts an ActionMailer-built Mail::Message into the keyword arguments
25
24
  # accepted by {Message#initialize}.
@@ -124,13 +123,16 @@ module Cloudflare
124
123
  end
125
124
 
126
125
  # Register the delivery method when ActionMailer is present. Guarded so this
127
- # file is safe to require in a non-Rails process.
128
- if defined?(ActiveSupport)
129
- ActiveSupport.on_load(:action_mailer) do
130
- add_delivery_method :cloudflare, Cloudflare::EmailService::Rails::DeliveryMethod
131
- end
132
- elsif defined?(ActionMailer::Base)
126
+ # file is safe to require in a non-Rails process. ActionMailer::Base is checked
127
+ # first so that when the Railtie requires this inside an `on_load(:action_mailer)`
128
+ # hook (mailer already loaded), registration happens immediately rather than
129
+ # scheduling a second, nested hook that may not run.
130
+ if defined?(ActionMailer::Base)
133
131
  ActionMailer::Base.add_delivery_method(
134
132
  :cloudflare, Cloudflare::EmailService::Rails::DeliveryMethod
135
133
  )
134
+ elsif defined?(ActiveSupport)
135
+ ActiveSupport.on_load(:action_mailer) do
136
+ add_delivery_method :cloudflare, Cloudflare::EmailService::Rails::DeliveryMethod
137
+ end
136
138
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module Cloudflare
6
+ module EmailService
7
+ # Registers the `:cloudflare` ActionMailer delivery method automatically
8
+ # inside a Rails app, so adding the gem is enough — no explicit require.
9
+ #
10
+ # Loaded only when Rails is present (see cloudflare/email_service.rb), so the
11
+ # core gem stays Rails-free. Registration is inert until a host opts in with
12
+ # `config.action_mailer.delivery_method = :cloudflare`.
13
+ class Railtie < ::Rails::Railtie
14
+ initializer "cloudflare_email_service.action_mailer",
15
+ before: "action_mailer.set_configs" do
16
+ ActiveSupport.on_load(:action_mailer) do
17
+ require "cloudflare/email_service/rails"
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -36,8 +36,8 @@ module Cloudflare
36
36
  # Builds and sends a message. Accepts the same keyword arguments as
37
37
  # {Message#initialize}.
38
38
  # @return [Response]
39
- def send_email(**kwargs)
40
- deliver(Message.new(**kwargs))
39
+ def send_email(**)
40
+ deliver(Message.new(**))
41
41
  end
42
42
 
43
43
  # Sends a pre-built {Message} over SMTP.
@@ -54,7 +54,8 @@ module Cloudflare
54
54
  # Every other SMTP protocol error (busy, syntax, fatal, unknown, ...).
55
55
  raise ServerError, e.message
56
56
  rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET,
57
- Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError => e
57
+ Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError,
58
+ OpenSSL::SSL::SSLError => e
58
59
  raise NetworkError, "SMTP delivery failed: #{e.class}: #{e.message}"
59
60
  end
60
61
 
@@ -148,6 +149,7 @@ module Cloudflare
148
149
  def load_dependencies!
149
150
  require "mail"
150
151
  require "net/smtp"
152
+ require "openssl"
151
153
  rescue LoadError => e
152
154
  raise ConfigurationError,
153
155
  "the SMTP transport needs the `mail` gem — add `gem \"mail\"` " \
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cloudflare
4
4
  module EmailService
5
- VERSION = "0.0.1"
5
+ VERSION = "0.1.0"
6
6
  end
7
7
  end
@@ -51,8 +51,16 @@ module Cloudflare
51
51
  # Convenience: build a {Client} from the global config and send.
52
52
  # Accepts the same keyword arguments as {Message#initialize}.
53
53
  # @return [Response]
54
- def send_email(**kwargs)
55
- client.send_email(**kwargs)
54
+ def send_email(**)
55
+ client.send_email(**)
56
+ end
57
+
58
+ # Absolute path to the shipped Cloudflare Email Worker template that signs
59
+ # and forwards inbound mail to the Action Mailbox ingress. Set your app URL
60
+ # and secret in it, then deploy it.
61
+ # @return [String]
62
+ def worker_template_path
63
+ File.expand_path("../../templates/cloudflare_email_worker.js", __dir__)
56
64
  end
57
65
 
58
66
  # Cloudflare SMTP submission settings, in the shape both {SMTPClient} and
@@ -77,3 +85,7 @@ module Cloudflare
77
85
  end
78
86
  end
79
87
  end
88
+
89
+ # Auto-wire the ActionMailer delivery method when running inside Rails. Guarded
90
+ # so the core gem stays Rails-free everywhere else.
91
+ require_relative "email_service/railtie" if defined?(Rails::Railtie)
@@ -0,0 +1,41 @@
1
+ // Cloudflare Email Worker for cloudflare-email_service inbound (Action Mailbox).
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.
6
+ //
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).
12
+
13
+ export default {
14
+ async email(message, env) {
15
+ // arrayBuffer (not text) preserves the raw bytes of non-UTF-8 messages.
16
+ const raw = new Uint8Array(await new Response(message.raw).arrayBuffer());
17
+ const ts = Math.floor(Date.now() / 1000).toString();
18
+
19
+ const key = await crypto.subtle.importKey(
20
+ "raw", new TextEncoder().encode(env.CLOUDFLARE_EMAIL_INGRESS_SECRET),
21
+ { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
22
+ );
23
+ const signed = new Uint8Array([...new TextEncoder().encode(ts + "."), ...raw]);
24
+ const digest = await crypto.subtle.sign("HMAC", key, signed);
25
+ const sig = [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
26
+
27
+ const response = await fetch("https://your-app.example.com/rails/action_mailbox/cloudflare/inbound_emails", {
28
+ method: "POST",
29
+ headers: {
30
+ "Content-Type": "message/rfc822",
31
+ "X-CF-Email-Timestamp": ts,
32
+ "X-CF-Email-Signature": sig,
33
+ },
34
+ body: raw,
35
+ });
36
+
37
+ // Reject on failure so Cloudflare bounces the message rather than silently
38
+ // accepting (and dropping) it.
39
+ if (!response.ok) message.setReject(`ingress error ${response.status}`);
40
+ },
41
+ };
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.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Elvinas Predkelis
@@ -9,6 +9,34 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: actionmailbox
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: actionmailer
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
12
40
  - !ruby/object:Gem::Dependency
13
41
  name: mail
14
42
  requirement: !ruby/object:Gem::Requirement
@@ -37,6 +65,20 @@ dependencies:
37
65
  - - "~>"
38
66
  - !ruby/object:Gem::Version
39
67
  version: '5.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: railties
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '7.1'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '7.1'
40
82
  - !ruby/object:Gem::Dependency
41
83
  name: rake
42
84
  requirement: !ruby/object:Gem::Requirement
@@ -79,11 +121,11 @@ dependencies:
79
121
  - - "~>"
80
122
  - !ruby/object:Gem::Version
81
123
  version: '3.0'
82
- description: A small Ruby client for sending transactional email via the Cloudflare
83
- Email Service. The REST transport is dependency-free; the optional SMTP transport
84
- uses the `mail` gem only when selected.
124
+ description: A small Ruby client for the Cloudflare Email Service with zero runtime
125
+ dependencies and optional Rails integration (ActionMailer delivery and Action Mailbox
126
+ inbound).
85
127
  email:
86
- - elvinas@trip1.com
128
+ - elvinas@primevise.com
87
129
  executables: []
88
130
  extensions: []
89
131
  extra_rdoc_files: []
@@ -92,14 +134,18 @@ files:
92
134
  - LICENSE.txt
93
135
  - README.md
94
136
  - lib/cloudflare/email_service.rb
137
+ - lib/cloudflare/email_service/action_mailbox.rb
95
138
  - lib/cloudflare/email_service/client.rb
96
139
  - lib/cloudflare/email_service/configuration.rb
97
140
  - lib/cloudflare/email_service/errors.rb
141
+ - lib/cloudflare/email_service/inbound.rb
98
142
  - lib/cloudflare/email_service/message.rb
99
143
  - lib/cloudflare/email_service/rails.rb
144
+ - lib/cloudflare/email_service/railtie.rb
100
145
  - lib/cloudflare/email_service/response.rb
101
146
  - lib/cloudflare/email_service/smtp_client.rb
102
147
  - lib/cloudflare/email_service/version.rb
148
+ - templates/cloudflare_email_worker.js
103
149
  homepage: https://github.com/elvinaspredkelis/cloudflare-email_service
104
150
  licenses:
105
151
  - MIT
@@ -114,14 +160,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
114
160
  requirements:
115
161
  - - ">="
116
162
  - !ruby/object:Gem::Version
117
- version: '3.1'
163
+ version: '3.2'
118
164
  required_rubygems_version: !ruby/object:Gem::Requirement
119
165
  requirements:
120
166
  - - ">="
121
167
  - !ruby/object:Gem::Version
122
168
  version: '0'
123
169
  requirements: []
124
- rubygems_version: 3.6.7
170
+ rubygems_version: 3.6.9
125
171
  specification_version: 4
126
- summary: Send email through the Cloudflare Email Service (REST or SMTP).
172
+ summary: Send and receive email through the Cloudflare Email Service.
127
173
  test_files: []