cloudflare-email_service 0.0.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: e223425c0900fe465d1f698fa9a1cd8ae030e2e48c2c658216a0a2a87605ec12
4
+ data.tar.gz: ec9b3749cccfd800c335e16ec9b1c044d97fd993524abd44f578686cff03eaba
5
+ SHA512:
6
+ metadata.gz: 76f656827c724a23ae557cb71a375eeb1cf4a85497a2d260733c7f775a7d520375fc80fbb3f19db7ec24374dd8aca018b4d3a4ddeef838048a563f1118a741e3
7
+ data.tar.gz: ee6b18139c3eef6372ec19a916317b76819d6ea2ee5d605aaa727d784ce2fe0abfea5944f618b369398af4c47eabc06b360727d2edd1d0178eb7f5175289ee3c
data/CHANGELOG.md ADDED
@@ -0,0 +1,31 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.0.1] - 2026-06-17
8
+
9
+ ### Added
10
+ - Initial release.
11
+ - REST transport: `Cloudflare::EmailService::Client#send_email` sends via
12
+ `POST /accounts/{account_id}/email/sending/send` using only the standard
13
+ library — zero runtime dependencies.
14
+ - SMTP transport: `Cloudflare::EmailService::SMTPClient#send_email` submits over
15
+ `smtp.mx.cloudflare.net:465` (implicit TLS). MIME is built with the `mail`
16
+ gem, an optional dependency loaded lazily only when SMTP is used.
17
+ - Transport selection via `config.transport = :rest | :smtp`; `send_email`,
18
+ `Response`, and the error classes are identical across transports.
19
+ - Optional, opt-in Rails integration (`require "cloudflare/email_service/rails"`)
20
+ registering a `:cloudflare` ActionMailer delivery method. The core gem stays
21
+ Rails-free; nothing Rails-related loads unless the adapter is required.
22
+ - `Cloudflare::EmailService.smtp_settings` helper for pointing ActionMailer's
23
+ built-in `:smtp` delivery (or any `mail` client) at Cloudflare.
24
+ - Global configuration via `Cloudflare::EmailService.configure` and the
25
+ `Cloudflare::EmailService.send_email` convenience method.
26
+ - Address normalization for strings, `{ email:, name: }` hashes, and arrays
27
+ (to / cc / bcc).
28
+ - Support for `reply_to`, attachments, and custom headers.
29
+ - Typed error classes (`ConfigurationError`, `ValidationError`,
30
+ `AuthenticationError`, `RequestError`, `RateLimitError`, `ServerError`,
31
+ `NetworkError`) and a `Response` wrapper.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Elvinas Predkelis
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,285 @@
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)
4
+
5
+ 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:
8
+
9
+ - **REST** (default) — talks to the send endpoint directly with the Ruby
10
+ standard library (`net/http`). **Zero dependencies.** No Rails, no HTTP gems.
11
+
12
+ ```
13
+ POST https://api.cloudflare.com/client/v4/accounts/{account_id}/email/sending/send
14
+ ```
15
+
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.
19
+
20
+ Same `send_email` call either way — pick the transport in configuration.
21
+
22
+ ## Installation
23
+
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
+ ```
32
+
33
+ Then run `bundle install`. Or install directly:
34
+
35
+ ```sh
36
+ gem install cloudflare-email_service
37
+ ```
38
+
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:
52
+
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
+ ```
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
79
+ ```
80
+
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
+ ```
91
+
92
+ ## Usage
93
+
94
+ ### Global configuration
95
+
96
+ ```ruby
97
+ require "cloudflare/email_service"
98
+
99
+ Cloudflare::EmailService.configure do |config|
100
+ config.account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
101
+ config.api_token = ENV["CLOUDFLARE_API_TOKEN"]
102
+ config.timeout = 30 # seconds (optional)
103
+ end
104
+
105
+ response = Cloudflare::EmailService.send_email(
106
+ from: "welcome@yourdomain.com",
107
+ to: "recipient@example.com",
108
+ subject: "Welcome!",
109
+ html: "<h1>Welcome</h1><p>Thanks for signing up.</p>",
110
+ text: "Welcome! Thanks for signing up.",
111
+ )
112
+
113
+ response.success? # => true
114
+ response.delivered # => ["recipient@example.com"]
115
+ ```
116
+
117
+ ### Explicit client
118
+
119
+ Skip the global configuration and pass credentials per client — handy when you
120
+ send from more than one account:
121
+
122
+ ```ruby
123
+ client = Cloudflare::EmailService::Client.new(
124
+ account_id: ENV["CLOUDFLARE_ACCOUNT_ID"],
125
+ api_token: ENV["CLOUDFLARE_API_TOKEN"],
126
+ )
127
+
128
+ client.send_email(
129
+ from: "welcome@yourdomain.com",
130
+ to: "recipient@example.com",
131
+ subject: "Welcome!",
132
+ text: "Thanks for signing up.",
133
+ )
134
+ ```
135
+
136
+ ### Addresses
137
+
138
+ `from`, `to`, `cc`, `bcc`, and `reply_to` accept:
139
+
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
144
+
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
+ ```
155
+
156
+ ### Attachments
157
+
158
+ Attachments are base64-encoded. The total message size (body + attachments)
159
+ must not exceed **5 MiB**.
160
+
161
+ ```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
+ )
178
+ ```
179
+
180
+ ### Custom headers
181
+
182
+ ```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
+ )
190
+ ```
191
+
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.
197
+
198
+ ```ruby
199
+ # config/initializers/cloudflare_email_service.rb
200
+ require "cloudflare/email_service/rails"
201
+
202
+ Cloudflare::EmailService.configure do |c|
203
+ c.account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
204
+ c.api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
205
+ # c.transport = :smtp # optional; defaults to :rest
206
+ end
207
+ ```
208
+
209
+ ```ruby
210
+ # config/environments/production.rb
211
+ config.action_mailer.delivery_method = :cloudflare
212
+ ```
213
+
214
+ Your mailers then send through Cloudflare unchanged:
215
+
216
+ ```ruby
217
+ class WelcomeMailer < ApplicationMailer
218
+ def welcome(user)
219
+ mail(from: "welcome@yourdomain.com", to: user.email, subject: "Welcome")
220
+ end
221
+ end
222
+ ```
223
+
224
+ Prefer ActionMailer's built-in SMTP delivery instead? Point it at Cloudflare
225
+ with the provided settings helper — no adapter required:
226
+
227
+ ```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
+ )
232
+ ```
233
+
234
+ ## Response
235
+
236
+ `send_email` returns a `Cloudflare::EmailService::Response`:
237
+
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 |
247
+
248
+ ## Errors
249
+
250
+ Non-2xx responses (and unsuccessful payloads) raise a typed error. All inherit
251
+ from `Cloudflare::EmailService::Error`:
252
+
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 |
262
+
263
+ API errors carry extra context:
264
+
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
+ ```
274
+
275
+ ## Development
276
+
277
+ ```sh
278
+ bundle install
279
+ bundle exec rake test # run the Minitest suite
280
+ bundle exec rubocop # lint
281
+ ```
282
+
283
+ ## License
284
+
285
+ Released under the [MIT License](LICENSE.txt).
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+ require "uri"
6
+
7
+ module Cloudflare
8
+ module EmailService
9
+ # HTTP client for the Cloudflare Email Service send endpoint.
10
+ #
11
+ # client = Cloudflare::EmailService::Client.new(
12
+ # account_id: "...", api_token: "..."
13
+ # )
14
+ # client.send_email(from: "a@x.com", to: "b@y.com",
15
+ # subject: "Hi", text: "Hello")
16
+ class Client
17
+ attr_reader :account_id, :api_token, :api_base, :open_timeout, :timeout
18
+
19
+ def initialize(account_id: nil, api_token: nil, api_base: nil,
20
+ open_timeout: nil, timeout: nil)
21
+ config = EmailService.configuration
22
+ @account_id = account_id || config.account_id
23
+ @api_token = api_token || config.api_token
24
+ @api_base = api_base || config.api_base
25
+ @open_timeout = open_timeout || config.open_timeout
26
+ @timeout = timeout || config.timeout
27
+
28
+ raise ConfigurationError, "no account_id configured" if @account_id.to_s.empty?
29
+ raise ConfigurationError, "no api_token configured" if @api_token.to_s.empty?
30
+ end
31
+
32
+ # Builds and sends a message. Accepts the same keyword arguments as
33
+ # {Message#initialize}.
34
+ # @return [Response]
35
+ def send_email(**kwargs)
36
+ deliver(Message.new(**kwargs))
37
+ end
38
+
39
+ # Sends a pre-built {Message}.
40
+ # @return [Response]
41
+ def deliver(message)
42
+ post(send_uri, message.validate!.to_h)
43
+ end
44
+
45
+ private
46
+
47
+ def send_uri
48
+ URI("#{api_base}/accounts/#{account_id}/email/sending/send")
49
+ end
50
+
51
+ def post(uri, payload)
52
+ request = Net::HTTP::Post.new(uri)
53
+ request["Authorization"] = "Bearer #{api_token}"
54
+ request["Content-Type"] = "application/json"
55
+ request["Accept"] = "application/json"
56
+ request["User-Agent"] = "cloudflare-email_service/#{VERSION} (ruby)"
57
+ request.body = JSON.generate(payload)
58
+
59
+ handle(http(uri).request(request))
60
+ rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET,
61
+ Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError => e
62
+ raise NetworkError, "request failed: #{e.class}: #{e.message}"
63
+ end
64
+
65
+ def http(uri)
66
+ client = Net::HTTP.new(uri.host, uri.port)
67
+ client.use_ssl = uri.scheme == "https"
68
+ client.open_timeout = open_timeout
69
+ client.read_timeout = timeout
70
+ client
71
+ end
72
+
73
+ def handle(http_response)
74
+ status = http_response.code.to_i
75
+ response = Response.new(status: status, body: parse(http_response.body))
76
+ return response if status.between?(200, 299) && response.success?
77
+
78
+ raise_error(status, response)
79
+ end
80
+
81
+ def parse(raw)
82
+ return {} if raw.nil? || raw.empty?
83
+
84
+ JSON.parse(raw)
85
+ rescue JSON::ParserError
86
+ { "success" => false, "errors" => [{ "message" => "non-JSON response body" }] }
87
+ end
88
+
89
+ def raise_error(status, response)
90
+ raise error_class_for(status).new(
91
+ summarize(response.errors),
92
+ status: status,
93
+ errors: response.errors,
94
+ response: response,
95
+ )
96
+ end
97
+
98
+ def error_class_for(status)
99
+ case status
100
+ when 401, 403 then AuthenticationError
101
+ when 429 then RateLimitError
102
+ when 500..599 then ServerError
103
+ else RequestError
104
+ end
105
+ end
106
+
107
+ # Collapses the Cloudflare "errors" array into a single human-readable line,
108
+ # prefixing each entry with its numeric code when one is present.
109
+ def summarize(errors)
110
+ lines = Array(errors).filter_map { |entry| describe_error(entry) }
111
+ lines.empty? ? "Cloudflare rejected the request" : lines.join(" | ")
112
+ end
113
+
114
+ def describe_error(entry)
115
+ return entry.to_s.empty? ? nil : entry.to_s unless entry.is_a?(Hash)
116
+
117
+ code = entry["code"]
118
+ text = entry["message"].to_s
119
+ return nil if text.empty? && code.nil?
120
+
121
+ code ? "[#{code}] #{text}".strip : text
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module EmailService
5
+ # Holds the transport selection, credentials, and connection options used
6
+ # when a client is built without explicit arguments. Defaults are read from
7
+ # the environment.
8
+ class Configuration
9
+ DEFAULT_API_BASE = "https://api.cloudflare.com/client/v4"
10
+ DEFAULT_SMTP_HOST = "smtp.mx.cloudflare.net"
11
+ DEFAULT_SMTP_PORT = 465
12
+ DEFAULT_TIMEOUT = 30
13
+
14
+ # @return [Symbol] :rest (default) or :smtp.
15
+ attr_accessor :transport
16
+ # @return [String, nil] Cloudflare account id (CLOUDFLARE_ACCOUNT_ID).
17
+ # Required for the REST transport; unused by SMTP.
18
+ attr_accessor :account_id
19
+ # @return [String, nil] API token (CLOUDFLARE_API_TOKEN). REST needs the
20
+ # "Email Sending: Send" scope; SMTP needs "Email Sending: Edit".
21
+ attr_accessor :api_token
22
+ # @return [String] base URL of the Cloudflare REST API.
23
+ attr_accessor :api_base
24
+ # @return [String] SMTP submission host.
25
+ attr_accessor :smtp_host
26
+ # @return [Integer] SMTP submission port (465, implicit TLS).
27
+ attr_accessor :smtp_port
28
+ # @return [Integer] connection-open timeout in seconds.
29
+ attr_accessor :open_timeout
30
+ # @return [Integer] read timeout in seconds.
31
+ attr_accessor :timeout
32
+
33
+ def initialize
34
+ @transport = ENV.fetch("CLOUDFLARE_EMAIL_TRANSPORT", "rest").to_sym
35
+ @account_id = ENV.fetch("CLOUDFLARE_ACCOUNT_ID", nil)
36
+ @api_token = ENV.fetch("CLOUDFLARE_API_TOKEN", nil)
37
+ @api_base = ENV.fetch("CLOUDFLARE_API_BASE", DEFAULT_API_BASE)
38
+ @smtp_host = ENV.fetch("CLOUDFLARE_SMTP_HOST", DEFAULT_SMTP_HOST)
39
+ @smtp_port = Integer(ENV.fetch("CLOUDFLARE_SMTP_PORT", DEFAULT_SMTP_PORT))
40
+ @open_timeout = DEFAULT_TIMEOUT
41
+ @timeout = DEFAULT_TIMEOUT
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module EmailService
5
+ # Base class for every error raised by this gem.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when required configuration (account_id / api_token) is missing.
9
+ class ConfigurationError < Error; end
10
+
11
+ # Raised when a message is missing required fields before it is sent.
12
+ class ValidationError < Error; end
13
+
14
+ # Base class for errors returned by the Cloudflare API.
15
+ class APIError < Error
16
+ # @return [Integer, nil] the HTTP status code.
17
+ attr_reader :status
18
+ # @return [Array] the Cloudflare "errors" array, when present.
19
+ attr_reader :errors
20
+ # @return [Response, nil] the wrapped API response.
21
+ attr_reader :response
22
+
23
+ def initialize(message = nil, status: nil, errors: nil, response: nil)
24
+ @status = status
25
+ @errors = errors || []
26
+ @response = response
27
+ super(message)
28
+ end
29
+ end
30
+
31
+ # 401 / 403 — missing, invalid, or insufficiently scoped API token.
32
+ class AuthenticationError < APIError; end
33
+
34
+ # 400 / 422 and other 4xx — the request was rejected as invalid.
35
+ class RequestError < APIError; end
36
+
37
+ # 429 — too many requests.
38
+ class RateLimitError < APIError; end
39
+
40
+ # 5xx — Cloudflare-side failure.
41
+ class ServerError < APIError; end
42
+
43
+ # Connection refused/reset, timeouts, DNS failures, etc.
44
+ class NetworkError < Error; end
45
+ end
46
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module EmailService
5
+ # Validates and serializes an outbound email into the JSON payload expected
6
+ # by the Cloudflare Email Service "send" endpoint.
7
+ #
8
+ # Addresses may be given as:
9
+ # * a String — "user@example.com" or "Display Name <user@example.com>"
10
+ # * a Hash — { email: "user@example.com", name: "Display Name" }
11
+ # (:address is accepted as an alias for :email)
12
+ # * an Array of any of the above (for to / cc / bcc)
13
+ class Message
14
+ attr_reader :from, :to, :cc, :bcc, :reply_to, :subject,
15
+ :html, :text, :attachments, :headers
16
+
17
+ def initialize(from:, to:, subject:, html: nil, text: nil, cc: nil,
18
+ bcc: nil, reply_to: nil, attachments: nil, headers: nil)
19
+ @from = from
20
+ @to = to
21
+ @cc = cc
22
+ @bcc = bcc
23
+ @reply_to = reply_to
24
+ @subject = subject
25
+ @html = html
26
+ @text = text
27
+ @attachments = attachments
28
+ @headers = headers
29
+ end
30
+
31
+ # @return [self]
32
+ # @raise [ValidationError] when the message cannot be sent.
33
+ def validate!
34
+ raise ValidationError, "from is required" if blank?(from)
35
+ raise ValidationError, "to is required" if blank?(to)
36
+ raise ValidationError, "subject is required" if blank?(subject)
37
+ if blank?(html) && blank?(text)
38
+ raise ValidationError, "provide html and/or text body content"
39
+ end
40
+
41
+ self
42
+ end
43
+
44
+ # @return [Hash] the request body, with nil/empty fields omitted.
45
+ def to_h
46
+ {
47
+ from: normalize_address(from),
48
+ to: normalize_recipients(to),
49
+ subject: subject,
50
+ cc: optional_recipients(cc),
51
+ bcc: optional_recipients(bcc),
52
+ reply_to: optional_address(reply_to),
53
+ html: presence(html),
54
+ text: presence(text),
55
+ headers: presence(headers),
56
+ attachments: optional_attachments(attachments),
57
+ }.compact
58
+ end
59
+
60
+ private
61
+
62
+ def optional_recipients(value)
63
+ blank?(value) ? nil : normalize_recipients(value)
64
+ end
65
+
66
+ def optional_address(value)
67
+ blank?(value) ? nil : normalize_address(value)
68
+ end
69
+
70
+ def optional_attachments(value)
71
+ blank?(value) ? nil : value.map { |a| normalize_attachment(a) }
72
+ end
73
+
74
+ def presence(value)
75
+ blank?(value) ? nil : value
76
+ end
77
+
78
+ def normalize_recipients(value)
79
+ list = value.is_a?(Array) ? value : [value]
80
+ normalized = list.reject { |v| blank?(v) }.map { |v| normalize_address(v) }
81
+ normalized.length == 1 ? normalized.first : normalized
82
+ end
83
+
84
+ def normalize_address(value)
85
+ return value if value.is_a?(String)
86
+ raise ValidationError, "unsupported address: #{value.inspect}" unless value.is_a?(Hash)
87
+
88
+ email = address_field(value, :email, :address)
89
+ raise ValidationError, "address hash must include an :email value" if blank?(email)
90
+
91
+ name = address_field(value, :name)
92
+ name ? "#{name} <#{email}>" : email
93
+ end
94
+
95
+ def address_field(hash, *keys)
96
+ keys.each do |key|
97
+ candidate = hash[key] || hash[key.to_s]
98
+ return candidate unless blank?(candidate)
99
+ end
100
+ nil
101
+ end
102
+
103
+ def normalize_attachment(att)
104
+ raise ValidationError, "attachment must be a Hash" unless att.is_a?(Hash)
105
+
106
+ h = att.transform_keys(&:to_sym)
107
+ raise ValidationError, "attachment requires :content" if blank?(h[:content])
108
+ raise ValidationError, "attachment requires :filename" if blank?(h[:filename])
109
+
110
+ out = { content: h[:content], filename: h[:filename] }
111
+ out[:type] = h[:type] if h[:type]
112
+ out[:disposition] = h[:disposition] if h[:disposition]
113
+ out
114
+ end
115
+
116
+ def blank?(value)
117
+ value.nil? || (value.respond_to?(:empty?) && value.empty?)
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cloudflare/email_service"
4
+
5
+ module Cloudflare
6
+ module EmailService
7
+ # Opt-in Rails / ActionMailer integration.
8
+ #
9
+ # This file is NOT loaded by the core gem — require it explicitly (e.g. from
10
+ # an initializer) to register a `:cloudflare` ActionMailer delivery method
11
+ # backed by the configured transport:
12
+ #
13
+ # # config/initializers/cloudflare_email_service.rb
14
+ # require "cloudflare/email_service/rails"
15
+ #
16
+ # Cloudflare::EmailService.configure do |c|
17
+ # c.account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
18
+ # c.api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
19
+ # end
20
+ #
21
+ # # config/environments/production.rb
22
+ # config.action_mailer.delivery_method = :cloudflare
23
+ module Rails
24
+ # Converts an ActionMailer-built Mail::Message into the keyword arguments
25
+ # accepted by {Message#initialize}.
26
+ module MessageMapping
27
+ module_function
28
+
29
+ def call(mail)
30
+ {
31
+ from: single_address(mail[:from]),
32
+ to: address_list(mail[:to]),
33
+ cc: address_list(mail[:cc]),
34
+ bcc: address_list(mail[:bcc]),
35
+ reply_to: single_address(mail[:reply_to]),
36
+ subject: mail.subject,
37
+ text: text_body(mail),
38
+ html: html_body(mail),
39
+ attachments: attachments(mail),
40
+ headers: custom_headers(mail),
41
+ }
42
+ end
43
+
44
+ def single_address(field)
45
+ field&.formatted&.first
46
+ end
47
+
48
+ def address_list(field)
49
+ field&.formatted
50
+ end
51
+
52
+ def text_body(mail)
53
+ return mail.text_part.decoded if mail.text_part
54
+ return nil if mail.multipart? || mail.mime_type == "text/html"
55
+
56
+ presence(mail.body.decoded)
57
+ end
58
+
59
+ def html_body(mail)
60
+ return mail.html_part.decoded if mail.html_part
61
+ return nil unless mail.mime_type == "text/html"
62
+
63
+ presence(mail.body.decoded)
64
+ end
65
+
66
+ def presence(string)
67
+ string.nil? || string.empty? ? nil : string
68
+ end
69
+
70
+ def attachments(mail)
71
+ return nil if mail.attachments.empty?
72
+
73
+ mail.attachments.map do |part|
74
+ {
75
+ content: [part.body.decoded].pack("m0"),
76
+ filename: part.filename,
77
+ type: part.mime_type,
78
+ disposition: part.inline? ? "inline" : "attachment",
79
+ }
80
+ end
81
+ end
82
+
83
+ # Forwarded headers: anything custom (X-*) plus threading headers. The
84
+ # structural headers (From/To/Subject/Content-Type/...) are mapped above.
85
+ PASSTHROUGH_HEADERS = %w[in-reply-to references].freeze
86
+
87
+ def custom_headers(mail)
88
+ headers = {}
89
+ mail.header.fields.each do |field|
90
+ name = field.name.to_s
91
+ forwardable = name.downcase.start_with?("x-") ||
92
+ PASSTHROUGH_HEADERS.include?(name.downcase)
93
+ headers[name] = field.value if forwardable
94
+ end
95
+ headers.empty? ? nil : headers
96
+ end
97
+ end
98
+
99
+ # ActionMailer delivery method. Credentials and transport come from
100
+ # {Cloudflare::EmailService.configure}; this just maps the message and
101
+ # hands it to the configured transport client. Inject `client:` in
102
+ # settings to override (used in tests).
103
+ class DeliveryMethod
104
+ attr_reader :settings
105
+
106
+ def initialize(settings = {})
107
+ @settings = settings || {}
108
+ end
109
+
110
+ # @param mail [Mail::Message] the message ActionMailer built.
111
+ # @return [Response]
112
+ def deliver!(mail)
113
+ client.deliver(Message.new(**MessageMapping.call(mail)))
114
+ end
115
+
116
+ private
117
+
118
+ def client
119
+ @client ||= settings[:client] || EmailService.client
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
125
+
126
+ # 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)
133
+ ActionMailer::Base.add_delivery_method(
134
+ :cloudflare, Cloudflare::EmailService::Rails::DeliveryMethod
135
+ )
136
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module EmailService
5
+ # Wraps the JSON body returned by the Cloudflare send endpoint.
6
+ class Response
7
+ # @return [Integer] the HTTP status code.
8
+ attr_reader :status
9
+ # @return [Hash] the parsed JSON body.
10
+ attr_reader :body
11
+
12
+ def initialize(status:, body:)
13
+ @status = status
14
+ @body = body || {}
15
+ end
16
+
17
+ # @return [Boolean] whether Cloudflare reported success.
18
+ def success?
19
+ body["success"] == true
20
+ end
21
+
22
+ # @return [Array] Cloudflare error objects, e.g.
23
+ # [{ "code" => 1001, "message" => "..." }].
24
+ def errors
25
+ body["errors"] || []
26
+ end
27
+
28
+ # @return [Array] informational messages from the API.
29
+ def messages
30
+ body["messages"] || []
31
+ end
32
+
33
+ # @return [Hash] the "result" object.
34
+ def result
35
+ body["result"] || {}
36
+ end
37
+
38
+ # @return [Array<String>] addresses Cloudflare accepted for delivery.
39
+ def delivered
40
+ result["delivered"] || []
41
+ end
42
+
43
+ # @return [Array<String>] addresses queued for later delivery.
44
+ def queued
45
+ result["queued"] || []
46
+ end
47
+
48
+ # @return [Array<String>] addresses that permanently bounced.
49
+ def permanent_bounces
50
+ result["permanent_bounces"] || []
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module EmailService
5
+ # Delivers a {Message} over Cloudflare's SMTP submission endpoint
6
+ # (smtp.mx.cloudflare.net:465, implicit TLS).
7
+ #
8
+ # MIME assembly is delegated to the `mail` gem, which is an *optional*
9
+ # dependency: it is required lazily the first time an SMTP client is built,
10
+ # so the REST transport stays dependency-free.
11
+ #
12
+ # client = Cloudflare::EmailService::SMTPClient.new(api_token: "...")
13
+ # client.send_email(from: "a@x.com", to: "b@y.com",
14
+ # subject: "Hi", text: "Hello")
15
+ class SMTPClient
16
+ # Cloudflare requires the literal string "api_token" as the SMTP username;
17
+ # the password is the API token itself.
18
+ SMTP_USERNAME = "api_token"
19
+
20
+ attr_reader :api_token, :host, :port, :open_timeout, :timeout
21
+
22
+ def initialize(api_token: nil, host: nil, port: nil,
23
+ open_timeout: nil, timeout: nil)
24
+ config = EmailService.configuration
25
+ @api_token = api_token || config.api_token
26
+ @host = host || config.smtp_host
27
+ @port = port || config.smtp_port
28
+ @open_timeout = open_timeout || config.open_timeout
29
+ @timeout = timeout || config.timeout
30
+
31
+ raise ConfigurationError, "no api_token configured" if @api_token.to_s.empty?
32
+
33
+ load_dependencies!
34
+ end
35
+
36
+ # Builds and sends a message. Accepts the same keyword arguments as
37
+ # {Message#initialize}.
38
+ # @return [Response]
39
+ def send_email(**kwargs)
40
+ deliver(Message.new(**kwargs))
41
+ end
42
+
43
+ # Sends a pre-built {Message} over SMTP.
44
+ # @return [Response]
45
+ def deliver(message)
46
+ message.validate!
47
+ envelope = build_envelope(message)
48
+ envelope.delivery_method(:smtp, smtp_settings)
49
+ transmit(envelope)
50
+ accepted(envelope)
51
+ rescue Net::SMTPAuthenticationError => e
52
+ raise AuthenticationError, e.message
53
+ rescue Net::SMTPError => e
54
+ # Every other SMTP protocol error (busy, syntax, fatal, unknown, ...).
55
+ raise ServerError, e.message
56
+ rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET,
57
+ Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError => e
58
+ raise NetworkError, "SMTP delivery failed: #{e.class}: #{e.message}"
59
+ end
60
+
61
+ private
62
+
63
+ def smtp_settings
64
+ EmailService.smtp_settings(
65
+ api_token: api_token, host: host, port: port,
66
+ open_timeout: open_timeout, timeout: timeout
67
+ )
68
+ end
69
+
70
+ # Seam for testing: the actual network send lives here on its own.
71
+ def transmit(envelope)
72
+ envelope.deliver!
73
+ end
74
+
75
+ def build_envelope(message)
76
+ body = message.to_h
77
+ envelope = Mail.new
78
+ apply_addresses(envelope, body)
79
+ envelope.subject = body[:subject]
80
+ apply_body(envelope, body[:text], body[:html])
81
+ apply_attachments(envelope, body[:attachments])
82
+ apply_headers(envelope, body[:headers])
83
+ envelope
84
+ end
85
+
86
+ def apply_addresses(envelope, body)
87
+ envelope.from = body[:from]
88
+ envelope.to = body[:to]
89
+ envelope.cc = body[:cc] if body[:cc]
90
+ envelope.bcc = body[:bcc] if body[:bcc]
91
+ envelope.reply_to = body[:reply_to] if body[:reply_to]
92
+ end
93
+
94
+ def apply_body(envelope, text, html)
95
+ if text && html
96
+ envelope.text_part = mime_part("text/plain", text)
97
+ envelope.html_part = mime_part("text/html", html)
98
+ elsif html
99
+ envelope.content_type = "text/html; charset=UTF-8"
100
+ envelope.body = html
101
+ else
102
+ envelope.body = text
103
+ end
104
+ end
105
+
106
+ def mime_part(type, content)
107
+ part = Mail::Part.new
108
+ part.content_type = "#{type}; charset=UTF-8"
109
+ part.body = content
110
+ part
111
+ end
112
+
113
+ def apply_attachments(envelope, attachments)
114
+ Array(attachments).each do |attachment|
115
+ options = {
116
+ mime_type: attachment[:type],
117
+ # Our attachment content is base64; hand `mail` the raw bytes.
118
+ # unpack1("m") is the stdlib base64 decode (no extra dependency).
119
+ content: attachment[:content].unpack1("m"),
120
+ }
121
+ # Honor :disposition so SMTP matches REST (e.g. "inline" vs "attachment").
122
+ options[:content_disposition] = attachment[:disposition] if attachment[:disposition]
123
+ envelope.attachments[attachment[:filename]] = options
124
+ end
125
+ end
126
+
127
+ def apply_headers(envelope, headers)
128
+ (headers || {}).each { |key, value| envelope[key.to_s] = value }
129
+ end
130
+
131
+ # SMTP gives no delivery report, so synthesize a Response that mirrors the
132
+ # REST one: every recipient (to + cc + bcc, as bare addresses) is reported
133
+ # accepted for delivery.
134
+ def accepted(envelope)
135
+ Response.new(
136
+ status: 202,
137
+ body: {
138
+ "success" => true,
139
+ "result" => {
140
+ "delivered" => envelope.destinations,
141
+ "queued" => [],
142
+ "permanent_bounces" => [],
143
+ },
144
+ },
145
+ )
146
+ end
147
+
148
+ def load_dependencies!
149
+ require "mail"
150
+ require "net/smtp"
151
+ rescue LoadError => e
152
+ raise ConfigurationError,
153
+ "the SMTP transport needs the `mail` gem — add `gem \"mail\"` " \
154
+ "to your Gemfile (#{e.message})"
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module EmailService
5
+ VERSION = "0.0.1"
6
+ end
7
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "email_service/version"
4
+ require_relative "email_service/errors"
5
+ require_relative "email_service/configuration"
6
+ require_relative "email_service/message"
7
+ require_relative "email_service/response"
8
+ require_relative "email_service/client"
9
+ require_relative "email_service/smtp_client"
10
+
11
+ module Cloudflare
12
+ # Send transactional email through the Cloudflare Email Service, over either
13
+ # the REST or the SMTP transport.
14
+ module EmailService
15
+ class << self
16
+ # @return [Configuration] the global default configuration.
17
+ def configuration
18
+ @configuration ||= Configuration.new
19
+ end
20
+
21
+ # Yields the global {Configuration} for setup.
22
+ #
23
+ # Cloudflare::EmailService.configure do |c|
24
+ # c.account_id = ENV["CLOUDFLARE_ACCOUNT_ID"]
25
+ # c.api_token = ENV["CLOUDFLARE_API_TOKEN"]
26
+ # end
27
+ #
28
+ # @return [Configuration]
29
+ def configure
30
+ yield configuration if block_given?
31
+ configuration
32
+ end
33
+
34
+ # Resets the global configuration. Mainly useful in tests.
35
+ # @return [Configuration]
36
+ def reset_configuration!
37
+ @configuration = Configuration.new
38
+ end
39
+
40
+ # Builds the client for the configured transport (:rest or :smtp).
41
+ # @return [Client, SMTPClient]
42
+ def client
43
+ case configuration.transport
44
+ when :rest then Client.new
45
+ when :smtp then SMTPClient.new
46
+ else
47
+ raise ConfigurationError, "unknown transport #{configuration.transport.inspect}"
48
+ end
49
+ end
50
+
51
+ # Convenience: build a {Client} from the global config and send.
52
+ # Accepts the same keyword arguments as {Message#initialize}.
53
+ # @return [Response]
54
+ def send_email(**kwargs)
55
+ client.send_email(**kwargs)
56
+ end
57
+
58
+ # Cloudflare SMTP submission settings, in the shape both {SMTPClient} and
59
+ # ActionMailer's built-in `:smtp` delivery method expect. Defaults come
60
+ # from the global configuration; pass keywords to override.
61
+ # @return [Hash]
62
+ def smtp_settings(api_token: nil, host: nil, port: nil,
63
+ open_timeout: nil, timeout: nil)
64
+ config = configuration
65
+ {
66
+ address: host || config.smtp_host,
67
+ port: port || config.smtp_port,
68
+ user_name: SMTPClient::SMTP_USERNAME,
69
+ password: api_token || config.api_token,
70
+ authentication: :plain,
71
+ enable_starttls_auto: false,
72
+ tls: true,
73
+ open_timeout: open_timeout || config.open_timeout,
74
+ read_timeout: timeout || config.timeout,
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
metadata ADDED
@@ -0,0 +1,127 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: cloudflare-email_service
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Elvinas Predkelis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: mail
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '2.7'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '2.7'
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rubocop
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: webmock
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - "~>"
73
+ - !ruby/object:Gem::Version
74
+ version: '3.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ 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.
85
+ email:
86
+ - elvinas@trip1.com
87
+ executables: []
88
+ extensions: []
89
+ extra_rdoc_files: []
90
+ files:
91
+ - CHANGELOG.md
92
+ - LICENSE.txt
93
+ - README.md
94
+ - lib/cloudflare/email_service.rb
95
+ - lib/cloudflare/email_service/client.rb
96
+ - lib/cloudflare/email_service/configuration.rb
97
+ - lib/cloudflare/email_service/errors.rb
98
+ - lib/cloudflare/email_service/message.rb
99
+ - lib/cloudflare/email_service/rails.rb
100
+ - lib/cloudflare/email_service/response.rb
101
+ - lib/cloudflare/email_service/smtp_client.rb
102
+ - lib/cloudflare/email_service/version.rb
103
+ homepage: https://github.com/elvinaspredkelis/cloudflare-email_service
104
+ licenses:
105
+ - MIT
106
+ metadata:
107
+ source_code_uri: https://github.com/elvinaspredkelis/cloudflare-email_service
108
+ changelog_uri: https://github.com/elvinaspredkelis/cloudflare-email_service/blob/main/CHANGELOG.md
109
+ rubygems_mfa_required: 'true'
110
+ rdoc_options: []
111
+ require_paths:
112
+ - lib
113
+ required_ruby_version: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - ">="
116
+ - !ruby/object:Gem::Version
117
+ version: '3.1'
118
+ required_rubygems_version: !ruby/object:Gem::Requirement
119
+ requirements:
120
+ - - ">="
121
+ - !ruby/object:Gem::Version
122
+ version: '0'
123
+ requirements: []
124
+ rubygems_version: 3.6.7
125
+ specification_version: 4
126
+ summary: Send email through the Cloudflare Email Service (REST or SMTP).
127
+ test_files: []