haskimail-rails 1.0.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +15 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/lib/generators/haskimail/install/install_generator.rb +32 -0
  6. data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
  7. data/lib/haskimail/attachment.rb +84 -0
  8. data/lib/haskimail/clients.rb +78 -0
  9. data/lib/haskimail/configuration.rb +89 -0
  10. data/lib/haskimail/connection.rb +268 -0
  11. data/lib/haskimail/errors.rb +130 -0
  12. data/lib/haskimail/paginator.rb +48 -0
  13. data/lib/haskimail/rails/delivery_method.rb +56 -0
  14. data/lib/haskimail/rails/mail_converter.rb +130 -0
  15. data/lib/haskimail/rails/mailer_helpers.rb +42 -0
  16. data/lib/haskimail/rails/message_options.rb +88 -0
  17. data/lib/haskimail/rails/railtie.rb +28 -0
  18. data/lib/haskimail/resources/account_templates.rb +53 -0
  19. data/lib/haskimail/resources/base.rb +75 -0
  20. data/lib/haskimail/resources/bounces.rb +43 -0
  21. data/lib/haskimail/resources/domains.rb +42 -0
  22. data/lib/haskimail/resources/emails.rb +149 -0
  23. data/lib/haskimail/resources/inbound_rules.rb +33 -0
  24. data/lib/haskimail/resources/message_streams.rb +46 -0
  25. data/lib/haskimail/resources/messages.rb +135 -0
  26. data/lib/haskimail/resources/servers.rb +89 -0
  27. data/lib/haskimail/resources/stats.rb +89 -0
  28. data/lib/haskimail/resources/suppressions.rb +39 -0
  29. data/lib/haskimail/resources/templates.rb +68 -0
  30. data/lib/haskimail/resources/webhooks.rb +80 -0
  31. data/lib/haskimail/response_handler.rb +68 -0
  32. data/lib/haskimail/secret.rb +40 -0
  33. data/lib/haskimail/serializer.rb +75 -0
  34. data/lib/haskimail/util.rb +110 -0
  35. data/lib/haskimail/version.rb +5 -0
  36. data/lib/haskimail/webhooks/endpoint.rb +146 -0
  37. data/lib/haskimail/webhooks/event.rb +103 -0
  38. data/lib/haskimail/webhooks/inbound_message.rb +100 -0
  39. data/lib/haskimail/webhooks.rb +87 -0
  40. data/lib/haskimail-rails.rb +19 -0
  41. data/lib/haskimail.rb +58 -0
  42. metadata +132 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: adf6acf3fcc25870152c555a7446a4de75c110db5f8afc1066ef529652791214
4
+ data.tar.gz: cb71e39592945d3dbcfc529c294a5c5da93a695b852f44fc66316e14cf9d4c9d
5
+ SHA512:
6
+ metadata.gz: c2ed34f23437d20f3ac005b434213e4d83ce4b3dd1d1aa0b566e834ad8f95f43eb8d080368c127474441955154ccb9893148aa5494af74e97080a40175881cea
7
+ data.tar.gz: 3ca5ebbae9e138e1689db9c937597ed027793abcd1a0371522879d35cea8d5b2ad4620c74d5d30bf8fa555e2d958359ab0d6d46c0f670db6be9e69ea2289e0fd
data/CHANGELOG.md ADDED
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 — 2026-11-12
4
+
5
+ Первый публичный релиз.
6
+
7
+ - `Haskimail::AccountClient#servers.issue_token(server_id, name:)` — выпуск дополнительного серверного API-ключа (`POST /servers/{id}/tokens/new`).
8
+ - ActionMailer delivery method `:haskimail` (теги, метаданные, каналы, трекинг, вложения, inline-картинки, шаблоны Haskimail, SMTP-заголовки `X-HM-*`).
9
+ - `Haskimail::ServerClient`: письма, пакеты, шаблоны, сообщения, открытия, клики, входящие, возвраты, каналы, статистика, вебхуки, правила для входящих, стоп-листы.
10
+ - `Haskimail::AccountClient`: серверы, домены, перенос шаблонов.
11
+ - Ретраи без дублей писем, keep-alive соединения, безопасные для потоков и `fork`, инструментирование через ActiveSupport::Notifications.
12
+ - `Haskimail::Webhooks`: разбор событий и входящих писем, проверка подписи и Basic auth, Rack-эндпоинт.
13
+ - Генератор `rails g haskimail:install`.
14
+ - Поддержка Ruby 3.1+ и Rails 6.1–8.x.
15
+ - Из `Servers::UPDATABLE` убрано `enable_smtp_api_error_hooks` — поле отсутствует в API и игнорировалось.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DashaMail
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,267 @@
1
+ # haskimail-rails
2
+
3
+ Ruby on Rails SDK для [Haskimail](https://haskimail.ru), сервиса транзакционных писем.
4
+
5
+ * Метод доставки ActionMailer `:haskimail` с тегами, метаданными, каналами, трекингом, вложениями и inline-картинками.
6
+ * Полное покрытие HTTP API: письма, шаблоны, сообщения, возвраты, каналы, статистика, вебхуки, правила для входящих, стоп-листы, серверы и домены.
7
+ * Безопасные ретраи с экспоненциальной задержкой и jitter: письмо **никогда** не отправится дважды.
8
+ * Приём вебхуков: Rack-эндпоинт с Basic auth и проверкой подписи в постоянное время, типизированные события.
9
+ * Из зависимостей только ActionMailer и `base64`. Keep-alive соединения, безопасные для потоков и `fork`. TLS 1.2+.
10
+ * Токены не попадают в логи, `inspect`, `to_json` и трекеры ошибок.
11
+
12
+ Требования: Ruby 3.1+, Rails / ActionMailer 6.1+.
13
+
14
+ ## Установка
15
+
16
+ ```ruby
17
+ # Gemfile
18
+ gem "haskimail-rails"
19
+ ```
20
+
21
+ ```sh
22
+ bundle install
23
+ bin/rails generate haskimail:install # создаст config/initializers/haskimail.rb
24
+ bin/rails credentials:edit
25
+ ```
26
+
27
+ ```yaml
28
+ haskimail:
29
+ server_token: "серверный API-ключ"
30
+ account_token: "API-ключ аккаунта" # нужен только для серверов, доменов и переноса шаблонов
31
+ webhook_key: "ключ подписи вебхуков"
32
+ webhook_password: "пароль для Basic auth вебхуков"
33
+ ```
34
+
35
+ ## ActionMailer
36
+
37
+ ```ruby
38
+ # config/environments/production.rb
39
+ config.action_mailer.delivery_method = :haskimail
40
+ # Токен берётся из Haskimail.config. Можно задать явно:
41
+ # config.action_mailer.haskimail_settings = { api_token: Rails.application.credentials.dig(:haskimail, :server_token) }
42
+ ```
43
+
44
+ ```ruby
45
+ class OrderMailer < ApplicationMailer
46
+ default from: "Магазин <shop@example.com>"
47
+
48
+ def created(order)
49
+ haskimail tag: "orders",
50
+ metadata: { order_id: order.id.to_s },
51
+ message_stream: "2081",
52
+ track_opens: true,
53
+ track_links: true
54
+
55
+ attachments["invoice.pdf"] = order.invoice_pdf
56
+ attachments.inline["logo.png"] = File.binread("app/assets/images/logo.png")
57
+ # во вьюхе: image_tag attachments["logo.png"].url
58
+
59
+ mail to: order.email, subject: "Заказ №#{order.number} оформлен"
60
+ end
61
+
62
+ # Шаблон, который хранится в Haskimail
63
+ def welcome(user)
64
+ haskimail_template "welcome", model: { "{{name}}" => user.name }, to: user.email
65
+ end
66
+ end
67
+ ```
68
+
69
+ `track_opens` и `track_links` для письма принимают `true`/`false`; без них действует настройка сервера. На уровне сервера `TrackLinks` задаётся строкой (`None`, `HtmlAndText`, `HtmlOnly`, `TextOnly`) через `account.servers.update(id, track_links: "None")`; `true` и `false` там тоже принимаются.
70
+
71
+ После отправки ответ API доступен в `message.haskimail.response`, ID письма в `message.haskimail.message_id`.
72
+
73
+ Поддерживаются и SMTP-заголовки Haskimail (`X-HM-Tag`, `X-HM-Metadata`, `X-HM-TrackOpens`, `X-HM-No-Track-Opens`, `X-HM-No-Track-Click`), поэтому один и тот же мейлер работает и через SMTP, и через API. Явные настройки `haskimail(...)` важнее заголовков. Остальные заголовки, включая `X-HM-Domain` и `X-HM-MessageID`, передаются в Haskimail как есть.
74
+
75
+ В development и test удобен тестовый токен: API проверяет запрос, но ничего не отправляет.
76
+
77
+ ```ruby
78
+ Haskimail.config.server_token = Haskimail::TEST_TOKEN if Rails.env.development? || Rails.env.test?
79
+ ```
80
+
81
+ ## HTTP API
82
+
83
+ ```ruby
84
+ client = Haskimail::ServerClient.new # токен из Haskimail.config / ENV["HASKIMAIL_SERVER_TOKEN"]
85
+
86
+ client.deliver(
87
+ from: "Магазин <shop@example.com>",
88
+ to: "ivan@example.com",
89
+ subject: "Ваш заказ №1042 оформлен",
90
+ html_body: "<p>Здравствуйте!</p>",
91
+ text_body: "Здравствуйте!",
92
+ tag: "orders",
93
+ metadata: { order_id: "1042" },
94
+ headers: { "X-Order-Id" => "1042" },
95
+ attachments: [Haskimail::Attachment.from_file("invoice.pdf")],
96
+ message_stream: "2081"
97
+ )
98
+ # => { to: "ivan@example.com", submitted_at: "2026-10-07 14:30:12", message_id: "6f1c…", error_code: 0, message: "OK" }
99
+ ```
100
+
101
+ Ключи можно передавать в Ruby-стиле (`html_body:` или `"html_body"`, подходят и `params`) или ровно как в API (`"HtmlBody"`). Если одно поле задано дважды, SDK выдаст ошибку, а не выберет одно молча. Получателей можно передать строкой через запятую или массивом. Ответы приходят как Hash с ключами-символами в snake_case. Пользовательские данные (`Metadata`, `TemplateModel`, `TestRenderModel`) передаются как есть, без преобразования ключей.
102
+
103
+ Вложения проще всего передавать через `Haskimail::Attachment`: он сам кодирует содержимое в Base64. В Hash-вложениях `content` уже должен быть в Base64. Для картинки внутри письма укажите `content_id: "logo.png"` и сошлитесь на неё в HTML как `<img src="cid:logo.png">`. Haskimail использует `ContentID` как Content-ID без изменений, поэтому SDK убирает префикс `cid:` и угловые скобки.
104
+
105
+ ### Пакетная отправка
106
+
107
+ ```ruby
108
+ results = client.deliver_batch(messages) # до 500 писем
109
+ failed = results.reject { |r| r[:error_code].zero? } # API отвечает 200, даже если часть писем отклонена
110
+
111
+ client.deliver_with_template(template_alias: "order-created",
112
+ template_model: { "{{name}}" => "Иван" },
113
+ from: "shop@example.com", to: "ivan@example.com")
114
+ client.deliver_batch_with_templates([...])
115
+ ```
116
+
117
+ ### Ресурсы сервера (`ServerClient`)
118
+
119
+ | Ресурс | Методы |
120
+ |---|---|
121
+ | `emails` | `deliver`, `deliver_batch`, `deliver_with_template`, `deliver_batch_with_templates` |
122
+ | `templates` | `get`, `list`, `each`, `create`, `update`, `patch`, `delete`, `validate` |
123
+ | `messages` | `outbound`, `each_outbound`, `outbound_details`, `opens`, `each_open`, `opens_for`, `clicks`, `each_click`, `clicks_for`, `inbound`, `each_inbound`, `inbound_details`, `bypass_inbound`, `retry_inbound` |
124
+ | `bounces` | `delivery_stats`, `list`, `each` |
125
+ | `message_streams` | `list`, `get`, `create`, `update`, `archive`, `unarchive` |
126
+ | `stats` | `overview`, `sends`, `bounces`, `spam_complaints`, `opens`, `clicks`, `open_platforms`, `click_platforms`, `email_clients`, `browser_families` |
127
+ | `webhooks` | `list`, `get`, `create`, `update`, `delete` |
128
+ | `inbound_rules` | `list`, `each`, `create`, `delete` |
129
+ | `suppressions` | `list` (`dump`), `create` (`suppress`), `delete` (`unsuppress`) |
130
+
131
+ ### Ресурсы аккаунта (`AccountClient`)
132
+
133
+ | Ресурс | Методы |
134
+ |---|---|
135
+ | `servers` | `get`, `list`, `each`, `create`, `update`, `delete` |
136
+ | `domains` | `list`, `each`, `get`, `create`, `delete`, `verify_dkim`, `verify_spf` |
137
+ | `templates` | `list`, `each` (шаблоны всех серверов), `validate`, `create`, `push` (перенос шаблонов между серверами) |
138
+
139
+ ```ruby
140
+ account = Haskimail::AccountClient.new
141
+ account.templates.push(source_server_id: 1, destination_server_id: 2, perform_changes: false) # сначала предпросмотр
142
+ ```
143
+
144
+ ### Фильтры, даты и пагинация
145
+
146
+ ```ruby
147
+ client.messages.outbound(tag: "orders", recipient: "ivan@example.com",
148
+ from_date: 1.week.ago, to_date: Date.current, count: 100)
149
+
150
+ # Ленивый обход всех страниц (с учётом лимита API count + offset ≤ 10 000)
151
+ client.bounces.each(type: "HardBounce").each { |bounce| ... }
152
+ client.messages.each_outbound(tag: "orders").first(1_000)
153
+ ```
154
+
155
+ `Date`, `Time` и `ActiveSupport::TimeWithZone` переводятся в московское время, как требует API. Неизвестные фильтры, ошибки в датах и нарушения лимитов API ловятся ещё до запроса с понятным `ArgumentError`.
156
+
157
+ ### Шаблоны
158
+
159
+ `templates.update` перезаписывает шаблон целиком (так работает API), поэтому все четыре поля обязательны. Чтобы поменять одно поле, есть `templates.patch(id, subject: "…")`: он читает шаблон и сохраняет остальные поля.
160
+
161
+ ## Ошибки
162
+
163
+ ```
164
+ Haskimail::Error
165
+ ├── ConfigurationError
166
+ ├── ConnectionError
167
+ │ └── TimeoutError
168
+ ├── InvalidResponseError
169
+ ├── WebhookVerificationError
170
+ └── ApiError # #status, #error_code, #body, #headers, #code?
171
+ ├── UnauthorizedError # 401
172
+ ├── ForbiddenError # 403, IP не в списке разрешённых
173
+ ├── NotFoundError # 404
174
+ ├── PayloadTooLargeError # 413, пакет больше 50 МБ
175
+ ├── UnprocessableEntityError# 422
176
+ ├── RateLimitError # 429
177
+ └── ServerError # 5xx
178
+ ├── InternalServerError # 500
179
+ └── ServiceUnavailableError # 503
180
+ ```
181
+
182
+ ```ruby
183
+ begin
184
+ client.deliver_with_template(...)
185
+ rescue Haskimail::UnprocessableEntityError => e
186
+ raise unless e.code?(Haskimail::ErrorCodes::TEMPLATE_NOT_FOUND)
187
+ end
188
+ ```
189
+
190
+ ## Ретраи и надёжность
191
+
192
+ | Ситуация | GET, PUT | POST (отправка), DELETE |
193
+ |---|---|---|
194
+ | Соединение не открылось (DNS, refused, open timeout) | повтор | повтор: запрос точно не ушёл |
195
+ | Таймаут чтения, обрыв, 5xx, 429 | повтор | **без повтора**: запрос мог быть обработан |
196
+
197
+ По умолчанию 2 повтора, экспоненциальная задержка с jitter, `Retry-After` учитывается (не дольше `retry_max_delay`). `PUT /messages/inbound/{id}/retry|bypass` не повторяются: повтор отправил бы письмо на ваш вебхук ещё раз.
198
+
199
+ ```ruby
200
+ Haskimail.configure do |c|
201
+ c.open_timeout = 5
202
+ c.read_timeout = 30
203
+ c.max_retries = 2
204
+ c.default_message_stream = "2081"
205
+ c.logger = Rails.logger # логируются только метод, путь, статус и время
206
+ end
207
+ ```
208
+
209
+ Каждый запрос публикуется в `ActiveSupport::Notifications` как `request.haskimail` с полями `method`, `path`, `attempt` и `status`, без токенов и персональных данных.
210
+
211
+ Keep-alive соединения хранятся отдельно для каждого потока и файбера и не переиспользуются после `fork` (Puma, Unicorn). Для серверов с файбером на запрос (Falcon) поставьте `persistent_connections = false`.
212
+
213
+ ## Вебхуки
214
+
215
+ ```ruby
216
+ # config/routes.rb
217
+ mount Haskimail::Webhooks::Endpoint.new(
218
+ username: "haskimail",
219
+ password: Rails.application.credentials.dig(:haskimail, :webhook_password),
220
+ signature_key: Rails.application.credentials.dig(:haskimail, :webhook_key)
221
+ ) { |event| HaskimailWebhook.create!(payload: event.to_h).enqueue_processing }, at: "/haskimail/webhooks"
222
+ ```
223
+
224
+ Входящее письмо с вложениями может весить около 14 МБ, поэтому лучше сначала сохранить его в базу или хранилище, а в очередь передать только ID записи.
225
+
226
+ В Haskimail укажите URL `https://haskimail:ПАРОЛЬ@example.com/haskimail/webhooks`.
227
+
228
+ ```ruby
229
+ class HaskimailWebhookJob < ApplicationJob
230
+ def perform(webhook_id)
231
+ event = Haskimail::Webhooks.parse(HaskimailWebhook.find(webhook_id).payload)
232
+ case event
233
+ when Haskimail::Webhooks::InboundMessage
234
+ Ticket.reply!(event.mailbox_hash, event.stripped_text_reply, event.attachments)
235
+ else
236
+ return if WebhookEvent.exists?(key: event.idempotency_key) # Haskimail может прислать событие повторно
237
+ User.find_by(email: event.recipient)&.disable_email! if event.hard_bounce? || event.complained?
238
+ end
239
+ end
240
+ end
241
+ ```
242
+
243
+ Безопасность эндпоинта:
244
+
245
+ * Basic auth обязателен: без него эндпоинт не запустится. Принимать события только по подписи можно с явным `allow_signature_only: true`, входящие письма тогда отклоняются. Для локальной разработки есть `allow_unauthenticated: true`. Заданный `signature_key` проверяется всегда.
246
+ * Basic auth проверяется до чтения тела запроса. Сравнение идёт в постоянное время.
247
+ * Событие с ключом подписи принимается, только если поле `secret` совпадает с `MD5(recipient + message_id + ключ)`.
248
+ * Входящие письма Haskimail не подписывает, поэтому они принимаются только при включённом Basic auth.
249
+ * Подпись Haskimail покрывает только `recipient` и `message_id`: перехваченный `secret` можно переиспользовать с другим типом события. Поэтому рекомендуем Basic auth вместе с подписью и HTTPS.
250
+ * Ответы: 200 — обработано, 401 — неверный логин или пароль, 403 — неверная подпись, 500 — упал ваш обработчик. Haskimail повторяет 5xx для всех вебхуков. Для входящих писем он повторяет и другие ответы, кроме 2xx и 403.
251
+
252
+ Все события также публикуются в `ActiveSupport::Notifications` как `webhook.haskimail` с полями `{ type:, event: }`.
253
+
254
+ Методы событий: `type` (`:delivered`, `:bounced`, `:complained`, `:opened`, `:clicked`, `:subscription_change`), предикаты (`bounced?`, `hard_bounce?`, …), `recipient`, `message_id`, `tag`, `metadata` (уже распарсенный Hash), `occurred_at` (UTC), `bounce_code`, `bounce_description`, `clicked_url`, `suppress_sending?`, `idempotency_key`.
255
+
256
+ Если обрабатываете вебхуки в своём контроллере, используйте `Haskimail::Webhooks.parse(request.raw_post)`, `Haskimail::Webhooks.verify_signature!(event, key:)` и `Haskimail::Webhooks.valid_basic_auth?(request.authorization, username:, password:)`.
257
+
258
+ ## Разработка
259
+
260
+ ```sh
261
+ bundle install
262
+ bundle exec rake # RSpec (покрытие ≥ 98% строк) + RuboCop
263
+ ```
264
+
265
+ ## Лицензия
266
+
267
+ MIT
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Haskimail
6
+ module Generators
7
+ # rails generate haskimail:install
8
+ class InstallGenerator < ::Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+ desc "Creates config/initializers/haskimail.rb"
11
+
12
+ def copy_initializer
13
+ template "haskimail.rb", "config/initializers/haskimail.rb"
14
+ end
15
+
16
+ def show_next_steps
17
+ say <<~TEXT
18
+
19
+ Haskimail is installed. Next steps:
20
+ 1. bin/rails credentials:edit and add:
21
+ haskimail:
22
+ server_token: "..."
23
+ account_token: "..." # optional, for servers/domains
24
+ webhook_password: "..." # optional, Basic auth for webhooks
25
+ webhook_key: "..." # optional, webhook signature key
26
+ 2. config/environments/production.rb:
27
+ config.action_mailer.delivery_method = :haskimail
28
+ TEXT
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Haskimail: https://haskimail.ru/developer
4
+ #
5
+ # Tokens are read from HASKIMAIL_SERVER_TOKEN / HASKIMAIL_ACCOUNT_TOKEN by default;
6
+ # values from credentials take precedence when present.
7
+ Haskimail.configure do |config|
8
+ credentials = Rails.application.credentials.haskimail || {}
9
+
10
+ config.server_token = credentials[:server_token] if credentials[:server_token]
11
+ config.account_token = credentials[:account_token] if credentials[:account_token]
12
+
13
+ # Stream used when a message does not set one explicitly.
14
+ # config.default_message_stream = "2081"
15
+
16
+ # Network settings (seconds). Only requests that are safe to repeat are
17
+ # retried, so an email is never sent twice.
18
+ # config.open_timeout = 5
19
+ # config.read_timeout = 30
20
+ # config.max_retries = 2
21
+
22
+ # config.logger = Rails.logger
23
+ end
24
+
25
+ # Send-only test token for development and test: the API validates requests
26
+ # but delivers nothing.
27
+ # Haskimail.config.server_token = Haskimail::TEST_TOKEN if Rails.env.development? || Rails.env.test?
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+
5
+ module Haskimail
6
+ # An email attachment in the API format.
7
+ #
8
+ # Haskimail::Attachment.from_file("invoice.pdf")
9
+ # # Inline image, referenced in HTML as <img src="cid:logo.png">
10
+ # Haskimail::Attachment.new(name: "logo.png", content: png_bytes, content_type: "image/png",
11
+ # content_id: "logo.png")
12
+ #
13
+ # The Content-ID is sent bare: Haskimail uses ContentID verbatim as the MIME
14
+ # Content-ID, so "cid:" prefixes and angle brackets are stripped.
15
+ class Attachment
16
+ # Extensions rejected by Haskimail (https://haskimail.ru/developer/user-guide/send-email-with-api).
17
+ FORBIDDEN_EXTENSIONS = %w[
18
+ vbs exe bin bat chm com cpl crt hlp hta inf ins isp jse lnk mdb pcd pif reg scr sct shs
19
+ vbe vba wsf wsh wsl msc msi msp mst
20
+ ].freeze
21
+
22
+ DEFAULT_CONTENT_TYPE = "application/octet-stream"
23
+
24
+ attr_reader :name, :content_type, :content_id
25
+
26
+ # @param content [String] raw (not Base64-encoded) bytes
27
+ def initialize(name:, content:, content_type: nil, content_id: nil)
28
+ @name = name.to_s
29
+ raise ArgumentError, "Attachment name is required" if @name.strip.empty?
30
+
31
+ self.class.assert_allowed!(@name)
32
+ @content = content.to_s.b
33
+ @content_type = content_type || self.class.guess_content_type(@name)
34
+ @content_id = self.class.normalize_content_id(content_id)
35
+ end
36
+
37
+ # "cid:logo.png" / "<logo.png>" -> "logo.png"; blank -> nil.
38
+ def self.normalize_content_id(value)
39
+ id = value.to_s.strip.delete_prefix("<").delete_suffix(">").sub(/\Acid:/i, "").strip
40
+ id = id.delete_prefix("<").delete_suffix(">").strip
41
+ return nil if id.empty?
42
+ raise ArgumentError, "Invalid ContentID #{value.inspect}" if id.match?(/[\s<>"\0]/)
43
+
44
+ id
45
+ end
46
+
47
+ def self.from_file(path, name: nil, content_type: nil, content_id: nil)
48
+ new(name: name || File.basename(path.to_s), content: File.binread(path),
49
+ content_type: content_type, content_id: content_id)
50
+ end
51
+
52
+ def self.assert_allowed!(filename)
53
+ extension = File.extname(filename.to_s).delete_prefix(".").downcase
54
+ return unless FORBIDDEN_EXTENSIONS.include?(extension)
55
+
56
+ raise ArgumentError, "Attachments with .#{extension} extension are rejected by Haskimail"
57
+ end
58
+
59
+ def self.guess_content_type(filename)
60
+ require "mini_mime"
61
+ MiniMime.lookup_by_filename(filename.to_s)&.content_type || DEFAULT_CONTENT_TYPE
62
+ rescue LoadError
63
+ DEFAULT_CONTENT_TYPE
64
+ end
65
+
66
+ def bytesize
67
+ @content.bytesize
68
+ end
69
+
70
+ def to_h
71
+ Util.compact(
72
+ "Name" => name,
73
+ "Content" => Base64.strict_encode64(@content),
74
+ "ContentType" => content_type,
75
+ "ContentID" => content_id
76
+ )
77
+ end
78
+ alias to_api to_h
79
+
80
+ def inspect
81
+ "#<#{self.class.name} name=#{name.inspect} content_type=#{content_type.inspect} bytes=#{bytesize}>"
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Haskimail
4
+ # Shared client behaviour: configuration, lazily built resources, safe #inspect.
5
+ class BaseClient
6
+ attr_reader :config
7
+
8
+ # @param token [String, nil] falls back to the global configuration
9
+ # @param options [Hash] per-client overrides of Haskimail::Configuration settings
10
+ def initialize(token = nil, **options)
11
+ @config = Haskimail.config.merge(options)
12
+ @connection = Connection.new(token: token || default_token, token_header: self.class::TOKEN_HEADER,
13
+ config: @config)
14
+ end
15
+
16
+ def inspect
17
+ "#<#{self.class.name} #{@connection.inspect}>"
18
+ end
19
+ alias to_s inspect
20
+
21
+ def self.resource(name, klass)
22
+ define_method(name) do
23
+ @resources ||= {}
24
+ @resources[name] ||= klass.new(@connection)
25
+ end
26
+ end
27
+ private_class_method :resource
28
+ end
29
+
30
+ # Client for everything that belongs to one server: sending, templates,
31
+ # messages, bounces, message streams, stats, webhooks, inbound rules, suppressions.
32
+ #
33
+ # client = Haskimail::ServerClient.new(ENV["HASKIMAIL_SERVER_TOKEN"])
34
+ # client.deliver(from: "shop@example.com", to: "ivan@example.com", subject: "Hi", text_body: "Hi")
35
+ class ServerClient < BaseClient
36
+ TOKEN_HEADER = "X-Haskimail-Server-Token"
37
+
38
+ resource :emails, Resources::Emails
39
+ resource :templates, Resources::Templates
40
+ resource :messages, Resources::Messages
41
+ resource :bounces, Resources::Bounces
42
+ resource :message_streams, Resources::MessageStreams
43
+ resource :stats, Resources::Stats
44
+ resource :webhooks, Resources::Webhooks
45
+ resource :inbound_rules, Resources::InboundRules
46
+ resource :suppressions, Resources::Suppressions
47
+
48
+ # Shortcuts for the most common calls.
49
+ def deliver(message) = emails.deliver(message)
50
+ def deliver_batch(messages) = emails.deliver_batch(messages)
51
+ def deliver_with_template(message) = emails.deliver_with_template(message)
52
+ def deliver_batch_with_templates(messages) = emails.deliver_batch_with_templates(messages)
53
+
54
+ private
55
+
56
+ def default_token
57
+ config.server_token
58
+ end
59
+ end
60
+
61
+ # Client for account-wide management: servers, domains, moving templates.
62
+ #
63
+ # account = Haskimail::AccountClient.new(ENV["HASKIMAIL_ACCOUNT_TOKEN"])
64
+ # account.domains.create("example.com")
65
+ class AccountClient < BaseClient
66
+ TOKEN_HEADER = "X-Haskimail-Account-Token"
67
+
68
+ resource :servers, Resources::Servers
69
+ resource :domains, Resources::Domains
70
+ resource :templates, Resources::AccountTemplates
71
+
72
+ private
73
+
74
+ def default_token
75
+ config.account_token
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module Haskimail
6
+ # Global and per-client settings.
7
+ #
8
+ # Haskimail.configure do |config|
9
+ # config.server_token = Rails.application.credentials.dig(:haskimail, :server_token)
10
+ # config.account_token = Rails.application.credentials.dig(:haskimail, :account_token)
11
+ # config.default_message_stream = "2081"
12
+ # end
13
+ class Configuration
14
+ DEFAULT_BASE_URL = "https://api.haskimail.ru"
15
+
16
+ SETTINGS = %i[
17
+ server_token account_token base_url open_timeout read_timeout write_timeout
18
+ max_retries retry_base_delay retry_max_delay logger default_message_stream
19
+ user_agent_suffix persistent_connections
20
+ ].freeze
21
+
22
+ attr_accessor(*SETTINGS)
23
+
24
+ def initialize
25
+ @server_token = ENV.fetch("HASKIMAIL_SERVER_TOKEN", nil)
26
+ @account_token = ENV.fetch("HASKIMAIL_ACCOUNT_TOKEN", nil)
27
+ @base_url = DEFAULT_BASE_URL
28
+ @open_timeout = 5
29
+ @read_timeout = 30
30
+ @write_timeout = 30
31
+ @max_retries = 2
32
+ @retry_base_delay = 0.5
33
+ @retry_max_delay = 8.0
34
+ @logger = nil
35
+ @default_message_stream = nil
36
+ @user_agent_suffix = nil
37
+ @persistent_connections = true
38
+ end
39
+
40
+ # Returns a copy with the given settings overridden; the receiver is untouched.
41
+ def merge(overrides)
42
+ unknown = overrides.keys.map(&:to_sym) - SETTINGS
43
+ raise ArgumentError, "Unknown Haskimail setting(s): #{unknown.join(', ')}" if unknown.any?
44
+
45
+ copy = dup
46
+ overrides.each { |key, value| copy.public_send(:"#{key}=", value) }
47
+ copy
48
+ end
49
+
50
+ def to_h
51
+ SETTINGS.to_h { |key| [key, public_send(key)] }
52
+ end
53
+
54
+ # Validates connection settings and returns the parsed base URI.
55
+ def base_uri
56
+ uri = URI.parse(base_url.to_s)
57
+ unless uri.is_a?(URI::HTTPS) && uri.host && !uri.host.empty?
58
+ raise ConfigurationError, "base_url must be an absolute https:// URL, got #{base_url.inspect}"
59
+ end
60
+ raise ConfigurationError, "base_url must not contain credentials" if uri.userinfo
61
+
62
+ uri
63
+ rescue URI::InvalidURIError => e
64
+ raise ConfigurationError, "Invalid base_url: #{e.message}"
65
+ end
66
+
67
+ SECRET_SETTINGS = %i[server_token account_token].freeze
68
+
69
+ def inspect
70
+ attrs = to_h.map do |key, value|
71
+ value = Util.redact(value) if SECRET_SETTINGS.include?(key)
72
+ "#{key}=#{value.inspect}"
73
+ end
74
+ "#<#{self.class.name} #{attrs.join(', ')}>"
75
+ end
76
+ alias to_s inspect
77
+
78
+ # Safe for logs and error trackers: tokens are redacted.
79
+ def as_json(*)
80
+ to_h.except(:logger).to_h do |key, value|
81
+ [key.to_s, SECRET_SETTINGS.include?(key) && value ? Secret::REDACTED : value]
82
+ end
83
+ end
84
+
85
+ def to_json(*args)
86
+ as_json.to_json(*args)
87
+ end
88
+ end
89
+ end