duva-rails 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: f1d0a7b608bf775dd3ec350b28df315969e99419004c724f389d29c3d4033a84
4
+ data.tar.gz: 30977e85fbe65ab750a0ca22d5cc5eacec48222c68e9091735fb7c6aeaf86f08
5
+ SHA512:
6
+ metadata.gz: ca4d394affd9cc2904606404ff19e1a8954501865e85262d564784967aae6aaea8df9f65b58b95f8e04a8c6e2484162cfc725bda4da32ac19004e03e7447a40d
7
+ data.tar.gz: 9aa14fa9618d458371956c0d8cc29c1e1f188b53a5af4e19899cff28a7268f7aa6ac747fc5bc3dedd8857737ae0fbcde87183d227d6d400fed8557cc93c5e047
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - First release: the `:duva` Action Mailer delivery method, built on `duva-mail`.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 9573-4562 Québec inc.
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,89 @@
1
+ # duva-rails
2
+
3
+ Action Mailer delivery method for [Duva](https://duva.ca), the transactional email API hosted in Canada. Your mailers,
4
+ `deliver_later` jobs and Devise emails go out through Duva with no change to your mailer code.
5
+
6
+ Requires Ruby 3.3+ and Rails (Action Mailer) 7.2 to 8.x. Built on the official
7
+ [`duva-mail`](https://rubygems.org/gems/duva-mail) library.
8
+
9
+ ## Install
10
+
11
+ ```ruby
12
+ # Gemfile
13
+ gem "duva-rails"
14
+ ```
15
+
16
+ You also need a Duva API key and a verified domain ([documentation](https://duva.ca/en/docs)).
17
+
18
+ ## Configure
19
+
20
+ ```ruby
21
+ # config/environments/production.rb
22
+ config.action_mailer.delivery_method = :duva
23
+ config.action_mailer.duva_settings = {
24
+ api_key: Rails.application.credentials.dig(:duva, :api_key), # or ENV["DUVA_API_KEY"]
25
+ domain: "example.com" # or ENV["DUVA_DOMAIN"]
26
+ }
27
+ config.action_mailer.raise_delivery_errors = true
28
+ ```
29
+
30
+ ```ruby
31
+ # app/mailers/application_mailer.rb
32
+ default from: "Example <notifications@example.com>" # the address must belong to the Duva domain
33
+ ```
34
+
35
+ Optional settings: `base_url` (default `https://api.duva.ca`), `timeout` (seconds, default 10), `max_retries` (default 2).
36
+ `api_key` and `domain` fall back to the `DUVA_API_KEY` and `DUVA_DOMAIN` environment variables.
37
+
38
+ ## What is sent
39
+
40
+ | Mail | Duva |
41
+ |---|---|
42
+ | From, To, Cc, Bcc, Reply-To | `from`, `to`, `cc`, `bcc`, `reply_to` (an address only in `smtp_envelope_to` is sent as Bcc) |
43
+ | `text` and `html` parts, or a single-part body | `text`, `html` |
44
+ | `attachments[...]`, `attachments.inline[...]` | `attachments` (inline ones keep their `content_id`) |
45
+ | `List-Unsubscribe`, `List-Unsubscribe-Post`, `List-Id`, `In-Reply-To`, `References`, `Auto-Submitted`, `Precedence`, `Importance`, `Feedback-ID`, and `X-*` headers | `headers` |
46
+ | `X-Duva-Tags`, `X-Duva-Metadata`, `X-Duva-Track`, `X-Idempotency-Key` | read by this gem, never sent (see below) |
47
+
48
+ Duva-specific options are set as headers in the mailer:
49
+
50
+ ```ruby
51
+ def welcome(user)
52
+ headers["X-Duva-Tags"] = "welcome, onboarding" # tags
53
+ headers["X-Duva-Metadata"] = { user_id: user.id }.to_json # metadata (JSON object)
54
+ headers["X-Duva-Track"] = "opens, clicks" # tracking (needs an HTML part)
55
+ headers["X-Idempotency-Key"] = "welcome-#{user.id}" # see "Idempotency"
56
+ mail(to: user.email, subject: "Welcome")
57
+ end
58
+ ```
59
+
60
+ Notes:
61
+
62
+ - Other headers are not forwarded (Duva rejects them), nor are `X-Duva*`, `X-Kumo*`, `X-Tenant*` and `X-Campaign*`.
63
+ - A display name Duva refuses (it contains `@`, quotes, `<>`, control characters or an encoded word `=?…?=`) is dropped;
64
+ the address is kept.
65
+ - Delivery is asynchronous: a successful `deliver_now` means Duva accepted the message, not that it was delivered. Use
66
+ Duva's webhooks or events for the outcome.
67
+ - A Duva error (`Duva::Error` and its subclasses, from the `duva-mail` library) is raised from `deliver_now`, and
68
+ retried by Active Job in `deliver_later` according to your job configuration.
69
+
70
+ ### Idempotency
71
+
72
+ A retried `deliver_later` job builds a new message, so a retry after a network timeout could send twice. Set a stable key
73
+ for it (an order ID, for example) with the `X-Idempotency-Key` header. Duva ignores a second message with the same key.
74
+
75
+ ## Testing your application
76
+
77
+ Use `ActionMailer::Base.delivery_method = :test` in your tests as usual. To exercise this gem without a network, give it
78
+ the fake transport of the client library:
79
+
80
+ ```ruby
81
+ transport = Duva::TestTransport.new { |_config, _spec| Duva::TestTransport.response("id" => "msg_...", "status" => "queued") }
82
+ ActionMailer::Base.duva_settings = { api_key: "dv_test", domain: "example.com", transport: transport }
83
+ # ... deliver ...
84
+ transport.requests.last.body # what would have been sent
85
+ ```
86
+
87
+ ## License
88
+
89
+ MIT.
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/duva/rails_version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "duva-rails"
7
+ spec.version = Duva::RAILS_VERSION
8
+ spec.summary = "Action Mailer delivery method for Duva, the transactional email API hosted in Canada."
9
+ spec.description = "Adds a :duva delivery method to Action Mailer: mailers, Active Job deliveries and " \
10
+ "Action Mailbox replies are sent through Duva. Built on the official duva-mail library."
11
+ spec.authors = ["9573-4562 Québec inc."]
12
+ spec.email = ["support@duva.ca"]
13
+ spec.license = "MIT"
14
+ spec.homepage = "https://duva.ca"
15
+ spec.metadata = {
16
+ "source_code_uri" => "https://github.com/duva-mail/duva-rails",
17
+ "documentation_uri" => "https://duva.ca/en/docs",
18
+ "changelog_uri" => "https://github.com/duva-mail/duva-rails/blob/main/CHANGELOG.md",
19
+ "rubygems_mfa_required" => "true"
20
+ }
21
+ spec.required_ruby_version = ">= 3.3"
22
+ spec.files = Dir.glob("lib/**/*.rb") + ["README.md", "LICENSE", "CHANGELOG.md", "duva-rails.gemspec"]
23
+ spec.require_paths = ["lib"]
24
+ spec.add_dependency "actionmailer", ">= 7.2", "< 9"
25
+ spec.add_dependency "duva-mail", "~> 0.2"
26
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Duva
6
+ # Action Mailer delivery method: sends a Mail::Message through the Duva API.
7
+ #
8
+ # config.action_mailer.delivery_method = :duva
9
+ # config.action_mailer.duva_settings = { api_key: ENV["DUVA_API_KEY"], domain: "example.com" }
10
+ #
11
+ # Settings: `api_key`, `domain` (both fall back to DUVA_API_KEY and DUVA_DOMAIN), `base_url`, `timeout`,
12
+ # `max_retries`; `transport:` takes a Duva::TestTransport in your own tests.
13
+ #
14
+ # Duva refuses a few things Mail allows, and this class translates rather than failing late: a display
15
+ # name Duva would reject is dropped (the address is kept), and only the headers Duva accepts are
16
+ # forwarded. The `X-Duva-*` headers below are read here and never sent.
17
+ class DeliveryMethod
18
+ # Duva's `headers` allowlist (docs/api.md); every other `X-*` is allowed except the reserved prefixes.
19
+ ALLOWED_HEADERS = %w[
20
+ list-unsubscribe list-unsubscribe-post list-id in-reply-to references auto-submitted precedence
21
+ importance feedback-id
22
+ ].freeze
23
+ RESERVED_PREFIXES = %w[x-kumo x-duva x-tenant x-campaign].freeze
24
+ # A name Duva refuses: `@`, quotes, angle brackets, backslash, control characters, encoded words.
25
+ UNSAFE_NAME = /[@"<>\\\u0000-\u001f\u007f]|=\?/
26
+ # A stable key so a retried job cannot send twice: read here, never forwarded.
27
+ IDEMPOTENCY_HEADER = "x-idempotency-key"
28
+
29
+ attr_accessor :settings
30
+
31
+ def initialize(settings = {})
32
+ @settings = settings
33
+ end
34
+
35
+ # Sends `mail`; returns the Duva::SendMessageResult (`id`, `status`, `replayed`) and sets the mail's
36
+ # Message-ID header to Duva's message id when it has none.
37
+ def deliver!(mail)
38
+ result = client.messages.deliver(**params(mail))
39
+ mail.message_id ||= result.id
40
+ result
41
+ end
42
+
43
+ private
44
+
45
+ def client
46
+ @client ||= Duva::Client.new(**client_options)
47
+ end
48
+
49
+ def client_options
50
+ options = settings.to_h.transform_keys(&:to_sym).slice(:api_key, :domain, :base_url, :timeout, :max_retries,
51
+ :transport)
52
+ options.compact
53
+ end
54
+
55
+ def params(mail)
56
+ from = mail[:from]&.addrs&.first
57
+ raise ArgumentError, "Duva: the message has no From address" if from.nil?
58
+
59
+ { from: format_address(from), to: addresses(mail[:to]), subject: mail.subject.to_s }
60
+ .merge(copies(mail), bodies(mail), reply_to(mail), from_headers(mail), attachments(mail))
61
+ end
62
+
63
+ def copies(mail)
64
+ { cc: addresses(mail[:cc]), bcc: blind_copies(mail) }.reject { |_key, list| list.empty? }
65
+ end
66
+
67
+ def reply_to(mail)
68
+ address = mail[:reply_to]&.addrs&.first
69
+ address ? { reply_to: format_address(address) } : {}
70
+ end
71
+
72
+ def attachments(mail)
73
+ list = mail.attachments.map { |attachment| to_attachment(attachment) }
74
+ list.empty? ? {} : { attachments: list }
75
+ end
76
+
77
+ def addresses(field)
78
+ (field&.addrs || []).map { |address| format_address(address) }
79
+ end
80
+
81
+ # Recipients only in the envelope (explicit smtp_envelope_to) and the Bcc header are blind copies.
82
+ def blind_copies(mail)
83
+ listed = (mail[:to]&.addrs.to_a + mail[:cc]&.addrs.to_a).map { |a| a.address.downcase }
84
+ from_header = mail[:bcc]&.addrs.to_a
85
+ from_envelope = mail.smtp_envelope_to.to_a.map { |address| Mail::Address.new(address) }
86
+ (from_header + from_envelope).uniq { |a| a.address.downcase }
87
+ .reject { |a| listed.include?(a.address.downcase) }
88
+ .map { |a| format_address(a) }
89
+ end
90
+
91
+ # `Name <address>`, or the bare address when Duva would refuse the name.
92
+ def format_address(address)
93
+ name = address.display_name.to_s
94
+ return address.address if name.empty? || UNSAFE_NAME.match?(name)
95
+
96
+ Duva::Address.format(address.address, name)
97
+ end
98
+
99
+ def bodies(mail)
100
+ html = text = nil
101
+ if mail.multipart?
102
+ html = decode(mail.html_part)
103
+ text = decode(mail.text_part)
104
+ elsif mail.mime_type == "text/html"
105
+ html = decode(mail)
106
+ else
107
+ text = decode(mail)
108
+ end
109
+ { html: html, text: text }.compact
110
+ end
111
+
112
+ def decode(part)
113
+ return nil if part.nil?
114
+
115
+ body = part.body.decoded.to_s.dup.force_encoding(Encoding::UTF_8)
116
+ body = body.encode(Encoding::UTF_8, part.charset || "UTF-8", invalid: :replace, undef: :replace) unless body.valid_encoding?
117
+ body.empty? ? nil : body
118
+ end
119
+
120
+ # Headers, tags, metadata, tracking and the idempotency key, all read from the Mail headers.
121
+ def from_headers(mail)
122
+ out = {}
123
+ headers = {}
124
+ mail.header.fields.each do |field|
125
+ name = field.name.to_s
126
+ lower = name.downcase
127
+ value = field.value.to_s
128
+ case lower
129
+ when IDEMPOTENCY_HEADER then out[:idempotency_key] = value
130
+ when "x-duva-tags" then out[:tags] = value.split(",").map(&:strip).reject(&:empty?)
131
+ when "x-duva-metadata" then out[:metadata] = parse_metadata(value)
132
+ when "x-duva-track" then out[:tracking] = parse_tracking(value)
133
+ else headers[name] = value if forwardable?(lower)
134
+ end
135
+ end
136
+ out[:headers] = headers unless headers.empty?
137
+ out.reject { |_key, value| value.respond_to?(:empty?) && value.empty? }
138
+ end
139
+
140
+ # `X-Duva-Metadata: {"order_id": "42"}`. JSON, because Mail turns `_` into `-` in a header NAME.
141
+ def parse_metadata(value)
142
+ parsed = JSON.parse(value)
143
+ raise ArgumentError, "Duva: X-Duva-Metadata must be a JSON object" unless parsed.is_a?(Hash)
144
+
145
+ parsed.to_h { |key, entry| [key.to_s, entry.to_s] }
146
+ rescue JSON::ParserError
147
+ raise ArgumentError, "Duva: X-Duva-Metadata must be a JSON object"
148
+ end
149
+
150
+ # `X-Duva-Track: opens, clicks`
151
+ def parse_tracking(value)
152
+ wanted = value.split(",").map { |entry| entry.strip.downcase }
153
+ { opens: wanted.include?("opens"), clicks: wanted.include?("clicks") }
154
+ end
155
+
156
+ def forwardable?(lower)
157
+ return true if ALLOWED_HEADERS.include?(lower)
158
+
159
+ lower.start_with?("x-") && RESERVED_PREFIXES.none? { |prefix| lower.start_with?(prefix) }
160
+ end
161
+
162
+ def truthy?(value)
163
+ %w[true 1 yes on].include?(value.to_s.strip.downcase)
164
+ end
165
+
166
+ def to_attachment(attachment)
167
+ Duva::Attachments.from_bytes(
168
+ attachment.filename || "attachment", attachment.body.decoded,
169
+ content_type: attachment.mime_type, content_id: (attachment.cid if attachment.inline?)
170
+ )
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duva
4
+ RAILS_VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Duva
4
+ # Registers the delivery method once Action Mailer is loaded, so `config.action_mailer.delivery_method = :duva`
5
+ # and `config.action_mailer.duva_settings = {...}` work in config/environments/*.rb.
6
+ #
7
+ # BEFORE `action_mailer.set_configs`: that initializer applies every `config.action_mailer.*` option to
8
+ # ActionMailer::Base, and `duva_settings=` only exists once the delivery method is registered.
9
+ class Railtie < ::Rails::Railtie
10
+ initializer "duva.add_delivery_method", before: "action_mailer.set_configs" do
11
+ ActiveSupport.on_load(:action_mailer) do
12
+ add_delivery_method :duva, Duva::DeliveryMethod
13
+ end
14
+ end
15
+ end
16
+ end
data/lib/duva-rails.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_mailer"
4
+ require "duva"
5
+ require_relative "duva/rails_version"
6
+ require_relative "duva/delivery_method"
7
+ require_relative "duva/railtie" if defined?(Rails::Railtie)
8
+
9
+ # Registers the :duva delivery method with Action Mailer. Outside a Rails application (a plain
10
+ # Action Mailer script), `ActionMailer::Base.add_delivery_method` is called here directly.
11
+ ActionMailer::Base.add_delivery_method(:duva, Duva::DeliveryMethod) unless defined?(Rails::Railtie)
metadata ADDED
@@ -0,0 +1,92 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: duva-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - 9573-4562 Québec inc.
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: actionmailer
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '7.2'
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: '9'
23
+ type: :runtime
24
+ prerelease: false
25
+ version_requirements: !ruby/object:Gem::Requirement
26
+ requirements:
27
+ - - ">="
28
+ - !ruby/object:Gem::Version
29
+ version: '7.2'
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: '9'
33
+ - !ruby/object:Gem::Dependency
34
+ name: duva-mail
35
+ requirement: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.2'
40
+ type: :runtime
41
+ prerelease: false
42
+ version_requirements: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.2'
47
+ description: 'Adds a :duva delivery method to Action Mailer: mailers, Active Job deliveries
48
+ and Action Mailbox replies are sent through Duva. Built on the official duva-mail
49
+ library.'
50
+ email:
51
+ - support@duva.ca
52
+ executables: []
53
+ extensions: []
54
+ extra_rdoc_files: []
55
+ files:
56
+ - CHANGELOG.md
57
+ - LICENSE
58
+ - README.md
59
+ - duva-rails.gemspec
60
+ - lib/duva-rails.rb
61
+ - lib/duva/delivery_method.rb
62
+ - lib/duva/rails_version.rb
63
+ - lib/duva/railtie.rb
64
+ homepage: https://duva.ca
65
+ licenses:
66
+ - MIT
67
+ metadata:
68
+ source_code_uri: https://github.com/duva-mail/duva-rails
69
+ documentation_uri: https://duva.ca/en/docs
70
+ changelog_uri: https://github.com/duva-mail/duva-rails/blob/main/CHANGELOG.md
71
+ rubygems_mfa_required: 'true'
72
+ post_install_message:
73
+ rdoc_options: []
74
+ require_paths:
75
+ - lib
76
+ required_ruby_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '3.3'
81
+ required_rubygems_version: !ruby/object:Gem::Requirement
82
+ requirements:
83
+ - - ">="
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ requirements: []
87
+ rubygems_version: 3.5.22
88
+ signing_key:
89
+ specification_version: 4
90
+ summary: Action Mailer delivery method for Duva, the transactional email API hosted
91
+ in Canada.
92
+ test_files: []