cloudflare-email_service 0.2.0 → 0.3.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: ae0f87f99efee9db13b22ae4cd2560f75878ee141ce0c34797da94dbb387a37c
4
- data.tar.gz: 23cd36dc8c771b45a649d93d6d9385ab76751755b72ffcd795840280c359a53f
3
+ metadata.gz: 3c8b69fe6e9c96aad0aeff171295d1af48d5e68254c5dc7f3bb238c514c86af5
4
+ data.tar.gz: bf78b28870b252900cf5784388ff23523e0d69e27cd4b404e6ae45e3430e0e0d
5
5
  SHA512:
6
- metadata.gz: d5d2e2efb6a2fa418400b2b0d4f26558ef3a647f880d89c81ee71ba1a278d7182ccb8c0db68116a9b106284fb0587b8ebac96a6ba0678aa3da61e7ac6b97b910
7
- data.tar.gz: c1db350ec8e6323eb9b7b9f3b5db198c1cf1801c3f790567d0d53c8f34c29ed7d9e2108a9601d9ad9c5b4ba7d9076bcee627856961cbdfb7ea2725f875484b7d
6
+ metadata.gz: d0257f7558d39424c3d8dfbca70519a24fc3ae4d82293d07afd230a0fb7c948f923e16a84ec03504a689f12ca379688c8274a394e4943e7c40b868275698ca9c
7
+ data.tar.gz: 1fca3c06a395a2a73f0301d5ca43e4d55984e7da4df352f21c7b66e65b5e70eb98b102aa5f3ce895344a24997cb883f6af7b49a636c603d01d879aa875fb657a
data/CHANGELOG.md CHANGED
@@ -6,6 +6,31 @@ All notable changes to this project are documented here. The format is based on
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.3.0] - 2026-06-21
10
+
11
+ ### Changed
12
+ - The bundled Email Worker no longer permanently rejects inbound mail on a
13
+ transient ingress failure. A `4xx` from the app is still rejected (permanent
14
+ bounce — a retry won't help), but a `5xx` or network failure now throws, so
15
+ the sending server retries delivery once the app recovers instead of bouncing
16
+ legitimate mail during a deploy or brief outage.
17
+
18
+ ### Added
19
+ - A Rails install generator: `bin/rails g cloudflare_email_service:install`
20
+ writes `config/initializers/cloudflare_email_service.rb` (inbound lines
21
+ commented out) and prints the remaining setup steps. Generator code lives
22
+ under `lib/generators` and loads only in a Rails generator context, so the
23
+ core gem stays Rails-free.
24
+ - Each send now publishes a `deliver.cloudflare_email_service` instrumentation
25
+ event — through `ActiveSupport::Notifications` when it is loaded, otherwise a
26
+ no-op. The payload carries the transport, recipient counts, and response
27
+ status (never message content); a failed send records the exception. Set
28
+ `config.instrumenter` to plug in a custom one.
29
+ - `RateLimitError#retry_after` exposes the `Retry-After` header from a `429`
30
+ response as an integer number of seconds (nil when absent or sent as an
31
+ HTTP-date), so callers can honor Cloudflare's backoff. The README now
32
+ documents the recommended `deliver_later` + `retry_on` retry pattern.
33
+
9
34
  ## [0.2.0] - 2026-06-19
10
35
 
11
36
  ### Added
data/README.md CHANGED
@@ -159,7 +159,14 @@ config.action_mailer.delivery_method = :cloudflare
159
159
 
160
160
  Credentials come from `CLOUDFLARE_ACCOUNT_ID` / `CLOUDFLARE_API_TOKEN` in the
161
161
  environment. To set them in code, choose the SMTP transport, or receive inbound
162
- mail, use a single initializer:
162
+ mail, use a single initializer. Generate it with
163
+
164
+ ```sh
165
+ bin/rails g cloudflare_email_service:install
166
+ ```
167
+
168
+ which writes the file below (inbound lines commented out) and prints the
169
+ remaining steps — or create it by hand:
163
170
 
164
171
  ```ruby
165
172
  # config/initializers/cloudflare_email_service.rb
@@ -224,6 +231,11 @@ above) and `CLOUDFLARE_EMAIL_INGRESS_SECRET` (matching the app):
224
231
  Visiting the worker's URL returns a `{ ok, configured }` health check — a quick
225
232
  way to confirm it's deployed and both vars are set.
226
233
 
234
+ On a `4xx` (bad signature, wrong media type) the Worker rejects the message
235
+ permanently; on a `5xx` or network failure it fails temporarily, so the sending
236
+ server retries once the app recovers instead of bouncing good mail. Prefer
237
+ zero-downtime deploys, since a long outage can still outlast the retry window.
238
+
227
239
  > [!NOTE]
228
240
  > The Worker sends `Content-Type: message/rfc822`; the ingress rejects anything
229
241
  > else with `415 Unsupported Media Type`.
@@ -276,6 +288,78 @@ end
276
288
 
277
289
  ---
278
290
 
291
+ ## Retries
292
+
293
+ This client doesn't retry a failed send — it raises and leaves the retry policy
294
+ to you. Cloudflare already retries _accepted_ mail server-side (soft bounces,
295
+ with exponential backoff); retries here are only about getting the request
296
+ accepted in the first place.
297
+
298
+ In Rails, the idiomatic place is the delivery job: send with `deliver_later` and
299
+ let Active Job retry the transient failures with backoff, while permanent ones
300
+ fail fast. `retry_on` is an Active Job method, so it goes on the delivery job —
301
+ not on the mailer:
302
+
303
+ ```ruby
304
+ # app/jobs/cloudflare_mail_delivery_job.rb
305
+ class CloudflareMailDeliveryJob < ActionMailer::MailDeliveryJob
306
+ retry_on Cloudflare::EmailService::RateLimitError, # 429
307
+ Cloudflare::EmailService::ServerError, # 5xx
308
+ Cloudflare::EmailService::NetworkError, # timeout, connection reset, TLS
309
+ wait: :polynomially_longer, attempts: 5
310
+ end
311
+
312
+ # app/mailers/application_mailer.rb
313
+ class ApplicationMailer < ActionMailer::Base
314
+ self.delivery_job = CloudflareMailDeliveryJob
315
+ end
316
+ ```
317
+
318
+ On a `429`, Cloudflare may send a `Retry-After` header. When present it's parsed
319
+ to an integer number of seconds and exposed as `RateLimitError#retry_after`, so
320
+ you can honor the backoff precisely:
321
+
322
+ ```ruby
323
+ rescue Cloudflare::EmailService::RateLimitError => e
324
+ e.retry_after # => 30 (seconds), or nil when not provided
325
+ end
326
+ ```
327
+
328
+ ---
329
+
330
+ ## Instrumentation
331
+
332
+ Every send publishes a `deliver.cloudflare_email_service` event. When
333
+ `ActiveSupport::Notifications` is loaded (e.g. in Rails) it's used
334
+ automatically — subscribe to log, time, or meter your sends:
335
+
336
+ ```ruby
337
+ ActiveSupport::Notifications.subscribe("deliver.cloudflare_email_service") do |*args|
338
+ event = ActiveSupport::Notifications::Event.new(*args)
339
+ Rails.logger.info(
340
+ "cloudflare email: transport=#{event.payload[:transport]} " \
341
+ "to=#{event.payload[:to]} status=#{event.payload[:status]} " \
342
+ "duration=#{event.duration.round}ms",
343
+ )
344
+ end
345
+ ```
346
+
347
+ The payload carries `:transport` (`:rest` / `:smtp`), recipient counts (`:to`,
348
+ `:cc`, `:bcc`), and `:status` on success — never addresses, subject, or body. A
349
+ failed send raises through, so the event records the exception (and the send
350
+ still raises to your caller).
351
+
352
+ Outside Rails, plug in any object with an `instrument(name, payload) { ... }`
353
+ method (the same shape as `ActiveSupport::Notifications`):
354
+
355
+ ```ruby
356
+ Cloudflare::EmailService.configure do |c|
357
+ c.instrumenter = MyInstrumenter
358
+ end
359
+ ```
360
+
361
+ ---
362
+
279
363
  ## Development
280
364
 
281
365
  ```sh
@@ -15,6 +15,8 @@ module Cloudflare
15
15
  # client.send_email(from: "a@x.com", to: "b@y.com",
16
16
  # subject: "Hi", text: "Hello")
17
17
  class Client
18
+ include Instrumentation
19
+
18
20
  attr_reader :account_id, :api_token, :api_base, :open_timeout, :timeout
19
21
 
20
22
  def initialize(account_id: nil, api_token: nil, api_base: nil,
@@ -40,7 +42,8 @@ module Cloudflare
40
42
  # Sends a pre-built {Message}.
41
43
  # @return [Response]
42
44
  def deliver(message)
43
- post(send_uri, message.validate!.to_h)
45
+ message.validate!
46
+ instrument_delivery(:rest, message) { post(send_uri, message.to_h) }
44
47
  end
45
48
 
46
49
  private
@@ -77,7 +80,15 @@ module Cloudflare
77
80
  response = Response.new(status: status, body: parse(http_response.body))
78
81
  return response if status.between?(200, 299) && response.success?
79
82
 
80
- raise_error(status, response)
83
+ raise_error(status, response, retry_after: retry_after_from(http_response))
84
+ end
85
+
86
+ # The `Retry-After` header as a non-negative Integer of seconds, or nil
87
+ # when absent or given as an HTTP-date rather than a delay.
88
+ def retry_after_from(http_response)
89
+ raw = http_response["retry-after"]
90
+ seconds = Integer(raw.to_s.strip, exception: false)
91
+ seconds if seconds&.>=(0)
81
92
  end
82
93
 
83
94
  def parse(raw)
@@ -88,12 +99,13 @@ module Cloudflare
88
99
  { "success" => false, "errors" => [{ "message" => "non-JSON response body" }] }
89
100
  end
90
101
 
91
- def raise_error(status, response)
102
+ def raise_error(status, response, retry_after: nil)
92
103
  raise error_class_for(status).new(
93
104
  summarize(response.errors),
94
105
  status: status,
95
106
  errors: response.errors,
96
107
  response: response,
108
+ retry_after: retry_after,
97
109
  )
98
110
  end
99
111
 
@@ -34,6 +34,12 @@ module Cloudflare
34
34
  # Cloudflare Email Worker signs with.
35
35
  attr_accessor :ingress_secret
36
36
 
37
+ # @return [#instrument] receives a "deliver.cloudflare_email_service"
38
+ # event on each send. Defaults to ActiveSupport::Notifications when it is
39
+ # loaded, otherwise a no-op. Assign any object with an
40
+ # `instrument(name, payload) { ... }` method.
41
+ attr_writer :instrumenter
42
+
37
43
  def initialize
38
44
  @transport = ENV.fetch("CLOUDFLARE_EMAIL_TRANSPORT", "rest").to_sym
39
45
  @account_id = ENV.fetch("CLOUDFLARE_ACCOUNT_ID", nil)
@@ -45,6 +51,24 @@ module Cloudflare
45
51
  @timeout = DEFAULT_TIMEOUT
46
52
  @ingress_secret = ENV.fetch("CLOUDFLARE_EMAIL_INGRESS_SECRET", nil)
47
53
  end
54
+
55
+ # Resolved on each read (never memoized) until one is set explicitly, so
56
+ # ActiveSupport::Notifications is picked up as soon as it loads — even if
57
+ # an earlier send already resolved the default to the no-op. An explicit
58
+ # assignment always wins.
59
+ def instrumenter
60
+ @instrumenter || default_instrumenter
61
+ end
62
+
63
+ private
64
+
65
+ def default_instrumenter
66
+ if defined?(ActiveSupport::Notifications)
67
+ ActiveSupport::Notifications
68
+ else
69
+ NullInstrumenter
70
+ end
71
+ end
48
72
  end
49
73
  end
50
74
  end
@@ -19,11 +19,17 @@ module Cloudflare
19
19
  attr_reader :errors
20
20
  # @return [Response, nil] the wrapped API response.
21
21
  attr_reader :response
22
+ # @return [Integer, nil] seconds to wait before retrying, parsed from the
23
+ # `Retry-After` response header whenever the API sends one (typically a
24
+ # 429, sometimes a 503). nil when the header is absent or not an integer
25
+ # number of seconds.
26
+ attr_reader :retry_after
22
27
 
23
- def initialize(message = nil, status: nil, errors: nil, response: nil)
28
+ def initialize(message = nil, status: nil, errors: nil, response: nil, retry_after: nil)
24
29
  @status = status
25
30
  @errors = errors || []
26
31
  @response = response
32
+ @retry_after = retry_after
27
33
  super(message)
28
34
  end
29
35
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Cloudflare
4
+ module EmailService
5
+ # Fallback instrumenter used when ActiveSupport::Notifications is absent.
6
+ # Mirrors its `instrument(name, payload) { ... }` signature so the two are
7
+ # interchangeable, and simply runs the block.
8
+ module NullInstrumenter
9
+ module_function
10
+
11
+ def instrument(_name, payload = {})
12
+ yield payload if block_given?
13
+ end
14
+ end
15
+
16
+ # Shared send instrumentation for the REST and SMTP clients. Wraps each
17
+ # delivery in a `"deliver.cloudflare_email_service"` event published through
18
+ # the configured instrumenter (ActiveSupport::Notifications when present,
19
+ # otherwise a no-op).
20
+ #
21
+ # The payload carries the transport and recipient counts — never addresses,
22
+ # subject, or body — plus the response status on success. On failure the
23
+ # block raises through, so an ActiveSupport::Notifications instrumenter
24
+ # records the exception on the event.
25
+ module Instrumentation
26
+ EVENT = "deliver.cloudflare_email_service"
27
+
28
+ private
29
+
30
+ def instrument_delivery(transport, message)
31
+ payload = {
32
+ transport: transport,
33
+ to: recipient_count(message.to),
34
+ cc: recipient_count(message.cc),
35
+ bcc: recipient_count(message.bcc),
36
+ }
37
+ EmailService.configuration.instrumenter.instrument(EVENT, payload) do
38
+ response = yield
39
+ payload[:status] = response.status
40
+ response
41
+ end
42
+ end
43
+
44
+ def recipient_count(value)
45
+ return 0 if value.nil?
46
+
47
+ value.is_a?(Array) ? value.length : 1
48
+ end
49
+ end
50
+ end
51
+ end
@@ -13,6 +13,8 @@ module Cloudflare
13
13
  # client.send_email(from: "a@x.com", to: "b@y.com",
14
14
  # subject: "Hi", text: "Hello")
15
15
  class SMTPClient
16
+ include Instrumentation
17
+
16
18
  # Cloudflare requires the literal string "api_token" as the SMTP username;
17
19
  # the password is the API token itself.
18
20
  SMTP_USERNAME = "api_token"
@@ -44,19 +46,24 @@ module Cloudflare
44
46
  # @return [Response]
45
47
  def deliver(message)
46
48
  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,
58
- OpenSSL::SSL::SSLError => e
59
- raise NetworkError, "SMTP delivery failed: #{e.class}: #{e.message}"
49
+ # Map errors inside the instrumentation block (not around it) so a
50
+ # subscriber sees the same gem error classes the caller does — matching
51
+ # the REST transport, which raises its wrapped errors from within.
52
+ instrument_delivery(:smtp, message) do
53
+ envelope = build_envelope(message)
54
+ envelope.delivery_method(:smtp, smtp_settings)
55
+ transmit(envelope)
56
+ accepted(envelope)
57
+ rescue Net::SMTPAuthenticationError => e
58
+ raise AuthenticationError, e.message
59
+ rescue Net::SMTPError => e
60
+ # Every other SMTP protocol error (busy, syntax, fatal, unknown, ...).
61
+ raise ServerError, e.message
62
+ rescue Timeout::Error, Errno::ECONNREFUSED, Errno::ECONNRESET,
63
+ Errno::EHOSTUNREACH, Errno::ETIMEDOUT, SocketError, IOError,
64
+ OpenSSL::SSL::SSLError => e
65
+ raise NetworkError, "SMTP delivery failed: #{e.class}: #{e.message}"
66
+ end
60
67
  end
61
68
 
62
69
  private
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Cloudflare
4
4
  module EmailService
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -5,6 +5,7 @@ require_relative "email_service/errors"
5
5
  require_relative "email_service/configuration"
6
6
  require_relative "email_service/message"
7
7
  require_relative "email_service/response"
8
+ require_relative "email_service/instrumentation"
8
9
  require_relative "email_service/client"
9
10
  require_relative "email_service/smtp_client"
10
11
 
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ # Generator-only namespace. The flat constant maps the gem name
6
+ # `cloudflare-email_service` onto the `rails g cloudflare_email_service:install`
7
+ # command (Rails derives the namespace from the first module). Runtime code
8
+ # lives under `Cloudflare::EmailService`; this file is loaded only by Rails'
9
+ # generator lookup, never at gem runtime, so the core stays Rails-free.
10
+ module CloudflareEmailService
11
+ module Generators
12
+ # Creates the initializer and prints the remaining setup steps.
13
+ #
14
+ # bin/rails g cloudflare_email_service:install
15
+ class InstallGenerator < Rails::Generators::Base
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ desc "Create a Cloudflare Email Service initializer and print the setup steps."
19
+
20
+ def create_initializer
21
+ template "cloudflare_email_service.rb",
22
+ "config/initializers/cloudflare_email_service.rb"
23
+ end
24
+
25
+ def print_next_steps
26
+ say "\ncloudflare-email_service installed.\n", :green
27
+ say <<~STEPS
28
+ Next steps:
29
+
30
+ 1. Point ActionMailer at Cloudflare (e.g. config/environments/production.rb):
31
+ config.action_mailer.delivery_method = :cloudflare
32
+
33
+ 2. Set credentials — in the initializer just created, or via the
34
+ environment: CLOUDFLARE_ACCOUNT_ID (REST only) and CLOUDFLARE_API_TOKEN.
35
+
36
+ 3. Inbound mail (Action Mailbox), optional — in the initializer,
37
+ uncomment `ingress_secret` and the `to_prepare` require, then set:
38
+ config.action_mailbox.ingress = :cloudflare
39
+ and deploy the bundled Worker (find it at
40
+ `Cloudflare::EmailService.worker_template_path`), setting its
41
+ CLOUDFLARE_EMAIL_INGRESS_URL and CLOUDFLARE_EMAIL_INGRESS_SECRET vars.
42
+ STEPS
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Configure the Cloudflare Email Service client. Credentials can also come from
4
+ # the environment (CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_API_TOKEN), in which case
5
+ # this block is optional.
6
+ Cloudflare::EmailService.configure do |c|
7
+ c.account_id = Rails.application.credentials.dig(:cloudflare, :account_id)
8
+ c.api_token = Rails.application.credentials.dig(:cloudflare, :api_token)
9
+ # c.transport = :smtp # optional; defaults to :rest (SMTP needs the `mail` gem)
10
+
11
+ # Inbound (Action Mailbox) only — must match the Worker's signing secret:
12
+ # c.ingress_secret = Rails.application.credentials.dig(:cloudflare, :ingress_secret)
13
+ end
14
+
15
+ # Inbound (Action Mailbox) only — load the :cloudflare ingress. In `to_prepare`
16
+ # so its controller superclass is autoloadable regardless of boot order. Pair it
17
+ # with `config.action_mailbox.ingress = :cloudflare` in your environment config.
18
+ # Rails.application.config.to_prepare do
19
+ # require "cloudflare/email_service/action_mailbox"
20
+ # end
@@ -48,8 +48,19 @@ export default {
48
48
  body: raw,
49
49
  });
50
50
 
51
- // Reject on failure so Cloudflare bounces the message rather than silently
52
- // accepting (and dropping) it.
53
- if (!response.ok) message.setReject(`ingress error ${response.status}`);
51
+ if (response.ok) return;
52
+
53
+ // 4xx: the app refused this message and a retry won't change the outcome
54
+ // (bad signature, wrong media type, unprocessable). Reject permanently so
55
+ // the sender gets a bounce instead of the message being silently dropped.
56
+ if (response.status >= 400 && response.status < 500) {
57
+ return message.setReject(`ingress rejected message (${response.status})`);
58
+ }
59
+
60
+ // 5xx (or any other non-2xx): a transient app problem — a deploy, a 502/503,
61
+ // a brief 500. Throw instead of setReject, exactly as a failed fetch() above
62
+ // already would, so the message is NOT permanently bounced and the sending
63
+ // server retries delivery once the app recovers.
64
+ throw new Error(`ingress temporarily unavailable (${response.status})`);
54
65
  },
55
66
  };
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Elvinas Predkelis
@@ -121,9 +121,9 @@ dependencies:
121
121
  - - "~>"
122
122
  - !ruby/object:Gem::Version
123
123
  version: '3.0'
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).
124
+ description: Send and receive email with the Cloudflare Email Service in Ruby. Zero
125
+ runtime dependencies, with optional ActionMailer and Action Mailbox adapters for
126
+ Rails.
127
127
  email:
128
128
  - elvinas@primevise.com
129
129
  executables: []
@@ -139,12 +139,15 @@ files:
139
139
  - lib/cloudflare/email_service/configuration.rb
140
140
  - lib/cloudflare/email_service/errors.rb
141
141
  - lib/cloudflare/email_service/inbound.rb
142
+ - lib/cloudflare/email_service/instrumentation.rb
142
143
  - lib/cloudflare/email_service/message.rb
143
144
  - lib/cloudflare/email_service/rails.rb
144
145
  - lib/cloudflare/email_service/railtie.rb
145
146
  - lib/cloudflare/email_service/response.rb
146
147
  - lib/cloudflare/email_service/smtp_client.rb
147
148
  - lib/cloudflare/email_service/version.rb
149
+ - lib/generators/cloudflare_email_service/install_generator.rb
150
+ - lib/generators/cloudflare_email_service/templates/cloudflare_email_service.rb
148
151
  - templates/cloudflare_email_worker.js
149
152
  homepage: https://github.com/elvinaspredkelis/cloudflare-email_service
150
153
  licenses: