unione-rails 1.1.3

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: c8d33828c26cc73b7a60e6eda7fb390bbc11355ee8c59c390579eb9a304f1434
4
+ data.tar.gz: f26567d24240704ee28d003cfb70a1192393661a7ad4d7f0a9c0182e725d2c4c
5
+ SHA512:
6
+ metadata.gz: 5f18c81a7d3ce510e6e14100ae72c5886d9c69f4b4920d74523595e40d1a4638ef840cc93c2178ae8ce65bd4d7101190e5eac7e13ebbd80cbcad886204f2c729
7
+ data.tar.gz: da6fd0b07611a2d119bb094b18f77880a0b05291f31638aab1924c80b63484286cc56d05e3b9494c1715adfd534416b6cad32e77736dca7d35c14d2993a3f3a1
data/AGENTS.md ADDED
@@ -0,0 +1,98 @@
1
+ # AGENTS.md
2
+
3
+ UniOne Ruby on Rails — Ruby on Rails integration of the UniOne Ruby SDK (`unione`) for the UniOne transactional email Web API (Web API 1.90).
4
+
5
+ ## Working on this repository
6
+
7
+ - **Generated code.** The whole package is generated by the UniOne team from the UniOne Web API OpenAPI spec (Web API 1.90): sources, docs, README.md and this file. Do not hand-edit it: the next release replaces the content of this repository. Report problems and requests in the issues of this repository (https://github.com/unione-repo/unione-rails).
8
+ - **Build and check:**
9
+
10
+ ```bash
11
+ gem build *.gemspec
12
+ ```
13
+
14
+ - **Source label.** Every request carries the query parameter `platform=rails.1.1.3,ruby-sdk.1.1.3` (this package, then the SDK it is built on).
15
+ - **Tests.** Before a release the generator installs the package into a test app of Ruby on Rails and checks the client, the webhook endpoint and the mailer against a mock of the API: no real API key, no real sends.
16
+ - **Release.** The version (1.1.3 in the gemspec) and the tag `v1.1.3` come from the generator; README.md states the Web API version the release was generated from.
17
+ - **Secrets.** Never commit API keys; the examples read `UNIONE_API_KEY` from the environment.
18
+
19
+ ## Integrating UniOne with this package
20
+
21
+ Full guide: [README.md](README.md). Ruby on Rails integration of the [UniOne Ruby SDK](https://github.com/unione-repo/unione-ruby-sdk) (`unione`): the API client in the application config (`config.unione`), a webhook endpoint with signature check, a Action Mailer transport that sends through `email/send`.
22
+
23
+ Install:
24
+
25
+ ```bash
26
+ bundle add unione-rails
27
+ ```
28
+
29
+ Requirements: Ruby 2.7+, Rails 6.1+.
30
+
31
+ Rules that generated code must follow:
32
+
33
+ - Server-side only. Read the API key from `UNIONE_API_KEY`; never put it in browser or mobile code, never commit it.
34
+ - Base URL `https://api.unione.io/en/transactional/api/v1` (an account registered on another host uses that one: `eu1.unione.io`, `us1.unione.io`); every method is POST with a JSON body; auth header `X-API-KEY`. The package does all of it.
35
+ - First email without DNS: `domain/get-sandbox-domain` → `confirmed-email/create` (the user clicks the link within 48 hours) → `email/send` from `noreply@<sandbox-domain>` to the confirmed address. Account API key only; up to 10 confirmed addresses, 100 emails a day; the domain expires after 31 days.
36
+ - Never ship a sandbox domain as the production sender (it is not in `domain/list`); read `from_email` from config. Production needs a verified domain: `domain/get-dns-records` → DNS records → `domain/validate-verification-record`, `domain/validate-dkim` → `domain/list`.
37
+ - Set `message.idempotence_key` on retryable sends; retry network errors, `429` and `5xx` with backoff, not other errors unchanged. Up to 500 recipients and 10 MB per request, each attachment up to 7 MB; always `body.html` or `body.plaintext`.
38
+ - Delivery events via webhook (`webhook/set`), not polling; the webhook endpoint of this package checks the `auth` signature of every notification.
39
+ - Keep the default unsubscribe and tracking behaviour; changes go through UniOne support.
40
+ - Do not invent methods or fields. Check the [README](README.md), the [API reference](https://docs.unione.io/en/web-api-ref) or [llms.txt](https://docs.unione.io/llms.txt).
41
+
42
+ ### Configuration
43
+
44
+ The API key goes to `UNIONE_API_KEY`.
45
+
46
+ ```ruby
47
+ # .env / environment: UNIONE_API_KEY=YOUR_API_KEY
48
+ # or config/application.rb:
49
+ config.unione.api_key = ENV.fetch('UNIONE_API_KEY')
50
+ config.unione.host = 'us1.unione.io'
51
+ config.action_mailer.delivery_method = :unione
52
+ ```
53
+
54
+ | Option | Environment variable | Default | Description |
55
+ |---|---|---|---|
56
+ | `config.unione.api_key` | `UNIONE_API_KEY` | | User or project API key (required). |
57
+ | `config.unione.host` | `UNIONE_HOST` | `api.unione.io` | API host (datacenter: `api.unione.io`, `eu1.unione.io`, `us1.unione.io`) or a full base URL. |
58
+ | `config.unione.timeout` | `UNIONE_TIMEOUT` | `30` | Request timeout, seconds. |
59
+ | `config.unione.webhook_key` | `UNIONE_WEBHOOK_KEY` | | API key the webhooks are signed with (the key of the user/project of the webhook), if it differs from the API key. |
60
+ | `config.unione.webhook_path` | `UNIONE_WEBHOOK_PATH` | `/unione/webhook` | Path of the webhook endpoint. |
61
+
62
+ ### Sending mail
63
+
64
+ Messages of the Action Mailer go through [email/send](https://docs.unione.io/en/web-api-ref#email-send): from, to/cc/bcc, reply-to, subject, HTML and text parts, attachments, inline images and `X-*` headers.
65
+
66
+ ```ruby
67
+ class OrderMailer < ApplicationMailer
68
+ def shipped(order)
69
+ headers['X-Order-Id'] = order.id.to_s
70
+ attachments['invoice.pdf'] = order.invoice_pdf
71
+ mail(to: order.email, cc: order.manager_email, subject: 'Order shipped')
72
+ end
73
+ end
74
+
75
+ message = OrderMailer.shipped(order).deliver_now
76
+ message.delivery_method.response['job_id']
77
+ ```
78
+
79
+ ### Webhooks
80
+
81
+ The endpoint answers `GET` with 200 OK (the URL check of [webhook/set](https://docs.unione.io/en/web-api-ref#webhook-set)) and accepts the `POST` notifications: it checks the `auth` field (MD5 of the request body in which the `auth` value is replaced by the API key), answers 401 to a wrong signature, and passes the notification and each of its events to the application.
82
+
83
+ ```ruby
84
+ # config/initializers/unione.rb
85
+ ActiveSupport::Notifications.subscribe('event.unione') do |*, payload|
86
+ event = payload[:event]
87
+ data = event.event_data
88
+ Rails.logger.info("UniOne #{event.event_name}: #{data&.email} #{data&.status}")
89
+ end
90
+ ```
91
+
92
+ Register the URL of the endpoint with `webhook/set`, e.g. `https://example.com/unione/webhook`.
93
+
94
+ ### Reference
95
+
96
+ - [UniOne Ruby SDK](https://github.com/unione-repo/unione-ruby-sdk): every API method, models, errors ([AGENTS.md](https://github.com/unione-repo/unione-ruby-sdk/blob/HEAD/AGENTS.md))
97
+ - [API reference](https://docs.unione.io/en/web-api-ref)
98
+ - [Full API reference for LLMs](https://docs.unione.io/llms-full.txt)
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 UniOne
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,274 @@
1
+ # UniOne Ruby on Rails 1.1.3
2
+
3
+ Web API 1.90
4
+
5
+ Quickstart guide: [Send emails with Ruby](https://docs.unione.io/en/send-with-ruby) · Docs for LLMs: [llms.txt](https://docs.unione.io/llms.txt) · [API reference](https://docs.unione.io/en/web-api-ref)
6
+
7
+ Ruby on Rails integration of the [UniOne Ruby SDK](https://github.com/unione-repo/unione-ruby-sdk) (`unione`): the API client in the application config (`config.unione`), a webhook endpoint with signature check, a Action Mailer transport that sends through `email/send`.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ bundle add unione-rails
13
+ ```
14
+
15
+ Requirements: Ruby 2.7+, Rails 6.1+.
16
+
17
+ ## Get an API key
18
+
19
+ 1. Sign up at [cp.unione.io](https://cp.unione.io/en/user/registration/).
20
+ 2. Copy your API key from [Settings → Security → API](https://cp.unione.io/en/settings/security/api).
21
+ 3. Put it in an environment variable:
22
+
23
+ ```bash
24
+ export UNIONE_API_KEY="your-api-key"
25
+ ```
26
+
27
+ The package reads it from `UNIONE_API_KEY` (see the configuration below).
28
+
29
+ > **Server-side only.** Use this package in backend code: API routes, server functions, workers.
30
+ > Never ship the API key to a browser or a mobile app, and never commit it.
31
+
32
+ ## Configuration
33
+
34
+ Settings are read from the environment by default:
35
+
36
+ ```ruby
37
+ # .env / environment: UNIONE_API_KEY=YOUR_API_KEY
38
+ # or config/application.rb:
39
+ config.unione.api_key = ENV.fetch('UNIONE_API_KEY')
40
+ config.unione.host = 'us1.unione.io'
41
+ config.action_mailer.delivery_method = :unione
42
+ ```
43
+
44
+ | Option | Environment variable | Default | Description |
45
+ |---|---|---|---|
46
+ | `config.unione.api_key` | `UNIONE_API_KEY` | | User or project API key (required). |
47
+ | `config.unione.host` | `UNIONE_HOST` | `api.unione.io` | API host (datacenter: `api.unione.io`, `eu1.unione.io`, `us1.unione.io`) or a full base URL. |
48
+ | `config.unione.timeout` | `UNIONE_TIMEOUT` | `30` | Request timeout, seconds. |
49
+ | `config.unione.webhook_key` | `UNIONE_WEBHOOK_KEY` | | API key the webhooks are signed with (the key of the user/project of the webhook), if it differs from the API key. |
50
+ | `config.unione.webhook_path` | `UNIONE_WEBHOOK_PATH` | `/unione/webhook` | Path of the webhook endpoint. |
51
+
52
+ The API key can also be stored in the credentials (`unione: { api_key: ... }`). The webhook route `unione_webhook` is a Rack endpoint (no CSRF, no session); `config.unione.webhook_enabled = false` turns it off.
53
+
54
+ ## Send your first email — no DNS setup
55
+
56
+ Every account can send test email right away from a **sandbox domain**: pre-verified, DKIM and SPF already set.
57
+ It delivers only to addresses you confirm.
58
+
59
+ The examples use the API client of the package (see the configuration above).
60
+
61
+ **1. Get your sandbox domain** (`domain/get-sandbox-domain`). The call creates the domain on first use and returns the same one afterwards.
62
+
63
+ ```ruby
64
+ response = UniOne::Wrapper.client.domain.get_sandbox_domain
65
+ puts response.domain
66
+ ```
67
+
68
+ **2. Confirm your inbox** (`confirmed-email/create`). UniOne sends a confirmation email; click the link in it within 48 hours.
69
+
70
+ ```ruby
71
+ ConfirmedEmail = UniOne::Api::ConfirmedEmail::Model
72
+
73
+ response = UniOne::Wrapper.client.confirmed_email.create(ConfirmedEmail::ConfirmedEmailRequest.new(
74
+ email: 'you@example.com'
75
+ ))
76
+ puts response.status
77
+ ```
78
+
79
+ Check the status with `confirmed-email/list` — your address should show `"status": "confirmed"`.
80
+
81
+ **3. Send the email** (`email/send`) from any address on the sandbox domain to the confirmed address.
82
+
83
+ ```ruby
84
+ Common = UniOne::Common::Model
85
+ Email = UniOne::Api::Email::Model
86
+
87
+ response = UniOne::Wrapper.client.email.send_email(Email::EmailSendRequest.new(
88
+ message: Email::MessageObject.new(
89
+ recipients: [
90
+ Email::MessageObjectRecipientsItem.new(
91
+ email: 'you@example.com'
92
+ )
93
+ ],
94
+ body: Common::BodyObject.new(
95
+ html: '<p>It works!</p>'
96
+ ),
97
+ subject: 'Hello from UniOne',
98
+ from_email: 'noreply@<sandbox-domain>'
99
+ )
100
+ ))
101
+ puts response.job_id
102
+ ```
103
+
104
+ ### What you get back
105
+
106
+ ```json
107
+ {
108
+ "status": "success",
109
+ "job_id": "1ZymBc-00041N-9X",
110
+ "emails": [
111
+ "you@example.com"
112
+ ]
113
+ }
114
+ ```
115
+
116
+ On error the API returns `status: "error"`, a `message` and a numeric `code`:
117
+
118
+ ```json
119
+ {
120
+ "status": "error",
121
+ "code": 102,
122
+ "message": "Error ID:83274F98-C7D5-11F1-B221-6214FF8707B1. Can not decode key wrongkey"
123
+ }
124
+ ```
125
+
126
+ ### Same flow with curl
127
+
128
+ ```bash
129
+ BASE=https://api.unione.io/en/transactional/api/v1
130
+
131
+ curl -s -X POST $BASE/domain/get-sandbox-domain.json \
132
+ -H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" -d '{}'
133
+
134
+ curl -s -X POST $BASE/confirmed-email/create.json \
135
+ -H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
136
+ -d '{"email": "you@example.com"}'
137
+
138
+ curl -s -X POST $BASE/email/send.json \
139
+ -H "Content-Type: application/json" -H "X-API-KEY: $UNIONE_API_KEY" \
140
+ -d '{
141
+ "message": {
142
+ "recipients": [
143
+ {
144
+ "email": "you@example.com"
145
+ }
146
+ ],
147
+ "from_email": "noreply@<sandbox-domain>",
148
+ "subject": "Hello from UniOne",
149
+ "body": {
150
+ "html": "<p>It works!</p>"
151
+ }
152
+ }
153
+ }'
154
+ ```
155
+
156
+ ## Sending mail
157
+
158
+ Messages of the Action Mailer go through [email/send](https://docs.unione.io/en/web-api-ref#email-send): from, to/cc/bcc, reply-to, subject, HTML and text parts, attachments, inline images and `X-*` headers.
159
+
160
+ ```ruby
161
+ class OrderMailer < ApplicationMailer
162
+ def shipped(order)
163
+ headers['X-Order-Id'] = order.id.to_s
164
+ attachments['invoice.pdf'] = order.invoice_pdf
165
+ mail(to: order.email, cc: order.manager_email, subject: 'Order shipped')
166
+ end
167
+ end
168
+
169
+ message = OrderMailer.shipped(order).deliver_now
170
+ message.delivery_method.response['job_id']
171
+ ```
172
+
173
+ ## Webhooks
174
+
175
+ The endpoint answers `GET` with 200 OK (the URL check of [webhook/set](https://docs.unione.io/en/web-api-ref#webhook-set)) and accepts the `POST` notifications: it checks the `auth` field (MD5 of the request body in which the `auth` value is replaced by the API key), answers 401 to a wrong signature, and passes the notification and each of its events to the application.
176
+
177
+ ```ruby
178
+ # config/initializers/unione.rb
179
+ ActiveSupport::Notifications.subscribe('event.unione') do |*, payload|
180
+ event = payload[:event]
181
+ data = event.event_data
182
+ Rails.logger.info("UniOne #{event.event_name}: #{data&.email} #{data&.status}")
183
+ end
184
+ ```
185
+
186
+ Register the URL of the endpoint with `webhook/set`, e.g. `https://example.com/unione/webhook`.
187
+
188
+ ## API client
189
+
190
+ Every section of the API is available on the client, see the [SDK docs](https://github.com/unione-repo/unione-ruby-sdk).
191
+
192
+ ```ruby
193
+ client = UniOne::Wrapper.client
194
+ info = client.system.info
195
+ puts info.accounting&.emails_sent
196
+ ```
197
+
198
+ ## Sending through the API
199
+
200
+ Besides the Action Mailer, mail can be sent through the API client directly. The client calls [email/send](https://docs.unione.io/en/web-api-ref#email-send) with all of its options: templates, substitutions, metadata, tracking. Fields of the request and other examples: [Ruby SDK docs](https://github.com/unione-repo/unione-ruby-sdk/blob/HEAD/docs/Email.md#send_email).
201
+
202
+ ```ruby
203
+ Common = UniOne::Common::Model
204
+ Email = UniOne::Api::Email::Model
205
+
206
+ response = UniOne::Wrapper.client.email.send_email(Email::EmailSendRequest.new(
207
+ message: Email::MessageObject.new(
208
+ recipients: [
209
+ Email::MessageObjectRecipientsItem.new(
210
+ email: 'user@example.com',
211
+ substitutions: {
212
+ 'CustomerId' => '12452',
213
+ 'to_name' => 'John Smith',
214
+ }
215
+ )
216
+ ],
217
+ body: Common::BodyObject.new(
218
+ html: '<b>Hello, {{to_name}}</b>',
219
+ plaintext: 'Hello, {{to_name}}'
220
+ ),
221
+ subject: 'UniOne test email ',
222
+ from_email: 'email@example.com',
223
+ from_name: 'John Smith'
224
+ )
225
+ ))
226
+ puts response.job_id
227
+ ```
228
+
229
+ ## Sandbox limits
230
+
231
+ - Sends only to confirmed addresses: up to 10 per account.
232
+ - Up to 100 emails a day.
233
+ - The sandbox domain expires after 31 days; call `domain/get-sandbox-domain` again to get a new one.
234
+ - Works only with the account API key, not a project key.
235
+ - Not listed in `domain/list`; the other `domain/*` methods do not apply to it.
236
+
237
+ The sandbox is for development and testing. Do not use a sandbox domain as the sender in production.
238
+
239
+ ## Before production: verify your domain
240
+
241
+ 1. `domain/get-dns-records` — returns the values for your DNS records.
242
+ 2. Add these records at your DNS provider (the domain page of the control panel shows them too):
243
+
244
+ | Type | Host | Value |
245
+ |---|---|---|
246
+ | TXT | `@` | the `verification-record` value (`unione-validate-hash=…`) |
247
+ | TXT | `<selector>._domainkey` (the selector is on the domain page of the control panel, e.g. `us`) | `v=DKIM1; k=rsa; p=<dkim>` |
248
+ | TXT | `@` | `v=spf1 include:spf.unione.io ~all` |
249
+ | CNAME | `_dmarc` | the `dmarc` value |
250
+
251
+ 3. `domain/validate-verification-record`, then `domain/validate-dkim`.
252
+ 4. `domain/list` — wait until the verification record is confirmed and DKIM is active.
253
+ 5. Read `from_email` from your config, so switching from the sandbox needs no code change.
254
+
255
+ ## Sending reliably
256
+
257
+ - **Retries:** set `message.idempotence_key` on sends that may be retried, so a repeated request does not send twice.
258
+ - **Limits:** up to 500 recipients and 10 MB per request; each attachment up to 7 MB.
259
+ - **Body:** always include `body.html` or `body.plaintext`.
260
+ - **Delivery status:** register a webhook (`webhook/set`) with a fast, idempotent HTTPS handler instead of polling.
261
+ - **Defaults:** keep the default unsubscribe footer and tracking; changing them requires UniOne support.
262
+ - **Errors:** retry `429` and `5xx` with backoff; do not retry other errors unchanged.
263
+
264
+ ## Links
265
+
266
+ - [Send emails with Ruby](https://docs.unione.io/en/send-with-ruby) and other [integration guides](https://docs.unione.io/en/integrations/)
267
+ - [Web API reference](https://docs.unione.io/en/web-api-ref)
268
+ - [llms.txt](https://docs.unione.io/llms.txt) for AI coding agents
269
+ - [UniOne MCP server and agent setup](https://unione.io/en/agents)
270
+ - [Support](https://cp.unione.io/en/support)
271
+
272
+ ## For package contributors
273
+
274
+ See [AGENTS.md](AGENTS.md): how this package is generated from the OpenAPI spec, built, tested and released.
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UniOne
4
+ module Wrapper
5
+ # Action Mailer delivery method :unione, sends through email/send:
6
+ #
7
+ # config.action_mailer.delivery_method = :unione
8
+ # config.action_mailer.unione_settings = { api_key: '...' } # optional, default: config.unione
9
+ #
10
+ # The API response ("job_id", "emails", "failed_emails") is in mail.delivery_method.response.
11
+ class DeliveryMethod
12
+ attr_accessor :settings
13
+ attr_reader :response
14
+
15
+ def initialize(settings = {})
16
+ @settings = settings || {}
17
+ @response = nil
18
+ end
19
+
20
+ def deliver!(mail)
21
+ @response = client.api_client.call('/email/send.json', { 'message' => EmailPayload.from_mail(mail) })
22
+ end
23
+
24
+ private
25
+
26
+ def client
27
+ return ::UniOne::Wrapper.client if @settings[:api_key].nil?
28
+
29
+ ::UniOne::Wrapper.build_client(@settings)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+
5
+ module UniOne
6
+ module Wrapper
7
+ # Converts a Mail::Message to the "message" of email/send.
8
+ #
9
+ # - recipients: to, cc and bcc, the display name goes to the "to_name" substitution;
10
+ # - with cc the "To" and "CC" headers are set, so every recipient sees the same headers;
11
+ # - inline attachments become inline_attachments named by their Content-ID (cid:...);
12
+ # - X-* and List-* / In-Reply-To / References headers are passed as is.
13
+ module EmailPayload
14
+ PASSED_HEADERS = %w[list-unsubscribe list-subscribe list-help list-owner list-archive in-reply-to references].freeze
15
+
16
+ def self.from_mail(mail)
17
+ from = addresses(mail, :from).first
18
+ raise ArgumentError, 'The message has no sender (From)' if from.nil?
19
+
20
+ message = {
21
+ 'recipients' => recipients(mail),
22
+ 'subject' => mail.subject.to_s,
23
+ 'from_email' => from.address
24
+ }
25
+ message['from_name'] = from.display_name if present?(from.display_name)
26
+ reply_to = addresses(mail, :reply_to).first
27
+ if reply_to
28
+ message['reply_to'] = reply_to.address
29
+ message['reply_to_name'] = reply_to.display_name if present?(reply_to.display_name)
30
+ end
31
+ body = body_parts(mail)
32
+ message['body'] = body unless body.empty?
33
+ headers = headers(mail)
34
+ message['headers'] = headers unless headers.empty?
35
+ mail.attachments.each do |part|
36
+ inline = part.inline? && present?(part.cid)
37
+ item = { 'type' => part.mime_type.to_s, 'name' => inline ? part.cid : part.filename.to_s,
38
+ 'content' => Base64.strict_encode64(part.decoded) }
39
+ (message[inline ? 'inline_attachments' : 'attachments'] ||= []) << item
40
+ end
41
+ message
42
+ end
43
+
44
+ def self.recipients(mail)
45
+ %i[to cc bcc].flat_map { |field| addresses(mail, field) }.map do |address|
46
+ recipient = { 'email' => address.address }
47
+ recipient['substitutions'] = { 'to_name' => address.display_name } if present?(address.display_name)
48
+ recipient
49
+ end
50
+ end
51
+
52
+ def self.body_parts(mail)
53
+ html = mail.html_part
54
+ text = mail.text_part
55
+ if html.nil? && text.nil? && !mail.multipart?
56
+ key = mail.mime_type.to_s == 'text/html' ? 'html' : 'plaintext'
57
+ return { key => mail.body.decoded }
58
+ end
59
+ { 'html' => html&.decoded, 'plaintext' => text&.decoded }.compact
60
+ end
61
+
62
+ def self.headers(mail)
63
+ headers = {}
64
+ cc = addresses(mail, :cc)
65
+ unless cc.empty?
66
+ headers['To'] = addresses(mail, :to).map(&:to_s).join(', ')
67
+ headers['CC'] = cc.map(&:to_s).join(', ')
68
+ end
69
+ mail.header.fields.each do |field|
70
+ name = field.name.to_s
71
+ next unless name.downcase.start_with?('x-') || PASSED_HEADERS.include?(name.downcase)
72
+
73
+ headers[name] = field.value.to_s
74
+ end
75
+ headers
76
+ end
77
+
78
+ def self.addresses(mail, field)
79
+ header = mail[field]
80
+ return [] if header.nil?
81
+
82
+ list = header.respond_to?(:address_list) ? header.address_list : nil
83
+ list ? list.addresses : Array(header.addrs)
84
+ end
85
+
86
+ def self.present?(value)
87
+ !value.nil? && !value.to_s.empty?
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/railtie'
4
+
5
+ module UniOne
6
+ module Wrapper
7
+ # config.unione settings, the :unione delivery method of Action Mailer and the webhook route.
8
+ class Railtie < ::Rails::Railtie
9
+ config.unione = ::UniOne::Wrapper.default_configuration
10
+
11
+ initializer 'unione.configure' do |app|
12
+ options = app.config.unione
13
+ if options.api_key.nil? || options.api_key.to_s.empty?
14
+ credentials = begin
15
+ app.credentials.unione
16
+ rescue StandardError
17
+ nil
18
+ end
19
+ options.api_key = credentials[:api_key] if credentials.respond_to?(:[])
20
+ end
21
+ ::UniOne::Wrapper.configuration = options
22
+ end
23
+
24
+ initializer 'unione.action_mailer' do
25
+ ActiveSupport.on_load(:action_mailer) do
26
+ add_delivery_method :unione, ::UniOne::Wrapper::DeliveryMethod
27
+ end
28
+ end
29
+
30
+ initializer 'unione.webhook_route' do |app|
31
+ options = app.config.unione
32
+ if options.webhook_enabled != false
33
+ app.routes.prepend do
34
+ match options.webhook_path, to: ::UniOne::Wrapper::WebhookEndpoint.new, via: %i[get head post],
35
+ as: 'unione_webhook'
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module UniOne
4
+ module Wrapper
5
+ VERSION = '1.1.3'
6
+ # The gem and its version in the "platform" query parameter of the requests, before the SDK one.
7
+ PLATFORM = 'rails.1.1.3'
8
+ end
9
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'active_support/notifications'
5
+
6
+ module UniOne
7
+ module Wrapper
8
+ # Rack endpoint of the webhooks (no CSRF, no session): GET/HEAD -> 200 (URL check of webhook/set),
9
+ # POST -> signature check (401 when wrong), then ActiveSupport::Notifications:
10
+ # "webhook.unione" - the whole notification: payload[:callback] (WebhookCallback), payload[:data] (Hash)
11
+ # "event.unione" - every event: payload[:event], payload[:user] (user/project of the event)
12
+ # Subscribers must be fast: the API waits for 200 OK for 3 seconds only.
13
+ class WebhookEndpoint
14
+ HEADERS = { 'content-type' => 'text/plain' }.freeze
15
+
16
+ def call(env)
17
+ return reply(200, 'OK') unless env['REQUEST_METHOD'] == 'POST'
18
+
19
+ input = env['rack.input']
20
+ body = input ? input.read.to_s : ''
21
+ input&.rewind if input.respond_to?(:rewind)
22
+ return reply(401, 'Invalid signature') unless WebhookSignature.verify(body, ::UniOne::Wrapper.webhook_key)
23
+
24
+ begin
25
+ callback = ::UniOne::Api::Webhook::WebhookApi.parse_callback(body)
26
+ data = JSON.parse(body)
27
+ rescue JSON::ParserError, TypeError, NoMethodError
28
+ return reply(400, 'Invalid body')
29
+ end
30
+
31
+ ActiveSupport::Notifications.instrument('webhook.unione', callback: callback, data: data)
32
+ (callback.events_by_user || []).each do |user|
33
+ (user.events || []).each do |event|
34
+ ActiveSupport::Notifications.instrument('event.unione', event: event, user: user)
35
+ end
36
+ end
37
+ reply(200, 'OK')
38
+ end
39
+
40
+ private
41
+
42
+ def reply(status, text)
43
+ [status, HEADERS.dup, [text]]
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest/md5'
4
+ require 'active_support/security_utils'
5
+
6
+ module UniOne
7
+ module Wrapper
8
+ # Signature of the webhook notifications: the "auth" field is the MD5 of the request body in which the "auth"
9
+ # value is replaced by the API key of the user/project of the webhook.
10
+ module WebhookSignature
11
+ PLACEHOLDER = '0' * 32
12
+
13
+ def self.verify(body, api_key)
14
+ return false if api_key.nil? || api_key.empty?
15
+
16
+ match = body.match(/"auth"\s*:\s*"(\h{32})"/)
17
+ return false if match.nil?
18
+
19
+ expected = Digest::MD5.hexdigest(body.gsub(match[1], api_key))
20
+ ActiveSupport::SecurityUtils.secure_compare(match[1].downcase, expected)
21
+ end
22
+
23
+ # Signs a body that has "auth" set to the placeholder, e.g. for tests.
24
+ def self.sign(body, api_key, placeholder = PLACEHOLDER)
25
+ body.gsub(placeholder, Digest::MD5.hexdigest(body.gsub(placeholder, api_key)))
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'unione'
4
+ require 'active_support'
5
+ require 'active_support/ordered_options'
6
+ require_relative 'wrapper/version'
7
+ require_relative 'wrapper/webhook_signature'
8
+ require_relative 'wrapper/email_payload'
9
+ require_relative 'wrapper/delivery_method'
10
+ require_relative 'wrapper/webhook_endpoint'
11
+
12
+ module UniOne
13
+ # Rails integration: UniOne::Wrapper.client is the UniOne API client configured by config.unione.
14
+ module Wrapper
15
+ class << self
16
+ # Settings: api_key, host, timeout, webhook_key, webhook_path, webhook_enabled (config.unione in Rails).
17
+ def configuration
18
+ @configuration ||= default_configuration
19
+ end
20
+
21
+ def configuration=(options)
22
+ @configuration = options
23
+ @client = nil
24
+ end
25
+
26
+ def configure
27
+ yield configuration
28
+ @client = nil
29
+ end
30
+
31
+ # @return [UniOne::UniOneClient] memoized client of the configuration
32
+ def client
33
+ @client ||= build_client(configuration)
34
+ end
35
+
36
+ # A client for other settings: { api_key:, host:, timeout: } (missing ones come from the configuration).
37
+ def build_client(options)
38
+ api_key = present(options[:api_key])
39
+ raise ArgumentError, 'UniOne: set config.unione.api_key (UNIONE_API_KEY)' if api_key.nil?
40
+
41
+ timeout = present(options[:timeout]) || configuration[:timeout] || 30
42
+ ::UniOne::UniOneClient.new(api_key, present(options[:host]), timeout: timeout.to_i, platform: PLATFORM)
43
+ end
44
+
45
+ # API key the webhook notifications are signed with.
46
+ def webhook_key
47
+ present(configuration[:webhook_key]) || present(configuration[:api_key]).to_s
48
+ end
49
+
50
+ def default_configuration
51
+ options = ActiveSupport::OrderedOptions.new
52
+ options.api_key = ENV['UNIONE_API_KEY']
53
+ options.host = ENV['UNIONE_HOST']
54
+ options.timeout = (ENV['UNIONE_TIMEOUT'] || 30).to_i
55
+ options.webhook_key = ENV['UNIONE_WEBHOOK_KEY']
56
+ options.webhook_path = ENV['UNIONE_WEBHOOK_PATH'] || '/unione/webhook'
57
+ options.webhook_enabled = ENV['UNIONE_WEBHOOK_ENABLED'] != 'false'
58
+ options
59
+ end
60
+
61
+ private
62
+
63
+ def present(value)
64
+ value.nil? || value.to_s.empty? ? nil : value
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ require_relative 'wrapper/railtie' if defined?(::Rails::Railtie)
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'unione/wrapper'
metadata ADDED
@@ -0,0 +1,110 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: unione-rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.1.3
5
+ platform: ruby
6
+ authors:
7
+ - UniOne
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-10-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: unione
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 1.1.3
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 1.1.3
27
+ - !ruby/object:Gem::Dependency
28
+ name: actionmailer
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '6.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '6.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: actionpack
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '6.1'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '6.1'
55
+ - !ruby/object:Gem::Dependency
56
+ name: railties
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '6.1'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '6.1'
69
+ description:
70
+ email:
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - AGENTS.md
76
+ - LICENSE
77
+ - README.md
78
+ - lib/unione-rails.rb
79
+ - lib/unione/wrapper.rb
80
+ - lib/unione/wrapper/delivery_method.rb
81
+ - lib/unione/wrapper/email_payload.rb
82
+ - lib/unione/wrapper/railtie.rb
83
+ - lib/unione/wrapper/version.rb
84
+ - lib/unione/wrapper/webhook_endpoint.rb
85
+ - lib/unione/wrapper/webhook_signature.rb
86
+ homepage: https://github.com/unione-repo/unione-rails
87
+ licenses:
88
+ - MIT
89
+ metadata:
90
+ documentation_uri: https://docs.unione.io/en/web-api-ref
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '2.7'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubygems_version: 3.5.22
107
+ signing_key:
108
+ specification_version: 4
109
+ summary: 'UniOne Web API for Rails: API client, Action Mailer delivery method, webhooks'
110
+ test_files: []