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 +7 -0
- data/CHANGELOG.md +31 -0
- data/LICENSE.txt +21 -0
- data/README.md +285 -0
- data/lib/cloudflare/email_service/client.rb +125 -0
- data/lib/cloudflare/email_service/configuration.rb +45 -0
- data/lib/cloudflare/email_service/errors.rb +46 -0
- data/lib/cloudflare/email_service/message.rb +121 -0
- data/lib/cloudflare/email_service/rails.rb +136 -0
- data/lib/cloudflare/email_service/response.rb +54 -0
- data/lib/cloudflare/email_service/smtp_client.rb +158 -0
- data/lib/cloudflare/email_service/version.rb +7 -0
- data/lib/cloudflare/email_service.rb +79 -0
- metadata +127 -0
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
|
+
[](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,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: []
|