haskimail 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 30aa44e35af165cc207605672108372f933419ece0f1eb0fd06790d75cb17cc2
4
+ data.tar.gz: 9f133a1b1743a61e3935e986d7dfd824450294066602399a75948147ca5a878c
5
+ SHA512:
6
+ metadata.gz: b3fb8e19e678dbb1cc37c325ef34e53c2ccd687c613721d06c76c7d3a057a79f80e13595ac5858f487120429144f9e763d469efd4c093f886cb93bcd8bec0c54
7
+ data.tar.gz: 3b3da21e1056287dd88b74b9f96f4820225b7b255bbcf70481716cc9af0fa7dfde05edc2720d60f757b65ea3cf3b2d96e5eede7219690d91503a6dbc51169447
data/CHANGELOG.md ADDED
@@ -0,0 +1,51 @@
1
+ # Изменения
2
+
3
+ Формат — [Keep a Changelog](https://keepachangelog.com/ru/1.1.0/), версии — [SemVer](https://semver.org/lang/ru/).
4
+
5
+ ## [1.0.0] — 2026-11-12
6
+
7
+ Первый публичный выпуск.
8
+
9
+ ### Добавлено
10
+
11
+ - `AccountClient#servers.issue_token(server_id, name:)` — выпуск дополнительного серверного
12
+ API-ключа (`POST /servers/{id}/tokens/new`); открытое значение ключа возвращается один раз.
13
+ - Все 61 метод справочника API Haskimail: отправка (одиночная, пакетная, по шаблону), шаблоны,
14
+ сообщения, открытия и клики, входящие письма, возвраты, серверы, каналы, домены, статистика,
15
+ вебхуки, правила для входящих, стоп-листы.
16
+ - `ServerClient` (ключ сервера) и `AccountClient` (ключ аккаунта: серверы, домены, перенос
17
+ шаблонов и данные всех серверов аккаунта).
18
+ - Разбор и проверка подписи вебхуков: доставка, отправка в песочнице, возврат, открытие, клик,
19
+ жалоба, изменение подписки; разбор входящих писем с декодированием вложений.
20
+ - Повторы с экспоненциальной задержкой и учётом `Retry-After` без риска повторной отправки письма.
21
+ - Проверка документированных лимитов до отправки: получатели, длина полей, размеры тел и вложений,
22
+ запрещённые типы файлов, защита от подмены заголовков письма.
23
+ - Ленивая пагинация, неизменяемые потокобезопасные клиенты и ответы, типизированные ошибки и все
24
+ коды ошибок API в `Haskimail::ErrorCodes`, тестовые адреса `Haskimail::TestAddresses`.
25
+ - RBS-сигнатуры публичного API и внутренностей; код проверяется Steep. Документация YARD — 100%.
26
+ - `Haskimail.parse_time` разбирает все форматы дат API: `YYYY-MM-DD HH:MM:SS` (МСК), ISO 8601 и RFC 2822
27
+ (`Date` входящего письма); нулевая дата `0000-00-00 00:00:00` даёт `nil`.
28
+ - Фильтр `status:` входящих писем принимает любой регистр (API сам отдаёт `"Failed"`, `"Blocked"`).
29
+ - Нет runtime-зависимостей; Ruby 3.1 – 4.0, json 2.x и 3.x.
30
+ - `BatchResult` — значение: `==`, `to_json`/`as_json`, `empty?`. `ApiObject` переживает `Marshal`
31
+ (например, `Rails.cache`) и остаётся неизменяемым.
32
+
33
+ ### Безопасность
34
+
35
+ - Только HTTPS с TLS 1.2+ и проверкой сертификата; `ca_file` добавляет CA к системным, а не
36
+ заменяет их.
37
+ - Ключи и пароль прокси не попадают в `inspect`, логи, YAML и JSON; клиенты, запросы и настройки
38
+ нельзя сериализовать через `Marshal`.
39
+ - Прокси из окружения (`https_proxy`, `HTTPS_PROXY`, `http_proxy`, `no_proxy` с `*` и `*.domain`)
40
+ проверяется так же, как явный: только `http://`; `HTTP_PROXY` игнорируется (httpoxy).
41
+ - Защита от подстановки заголовков письма при любом написании имён полей (`"subject"`, `:replyTo`),
42
+ в пользовательских заголовках и в `Name`/`ContentType`/`ContentID` вложений.
43
+ - Повтор POST только когда запрос точно не ушёл; это определяется по этапу соединения, а не по
44
+ классу ошибки.
45
+ - Неопределённый исход отправки — `UnexpectedResponseError`: нет или не число `ErrorCode`, число
46
+ результатов пакета не совпадает с числом писем.
47
+ - Ограничение размера ответа API (64 МБ) и тела вебхука (`max_size:`, ошибка
48
+ `WebhookPayloadTooLargeError`), защита от неоднозначных полей в подписанных событиях.
49
+ - Все строки запроса приводятся к UTF-8; битые данные отклоняются до отправки.
50
+
51
+ [1.0.0]: https://github.com/Dashamail/haskimail-ruby/releases/tag/v1.0.0
data/LICENSE 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,414 @@
1
+ <a href="https://haskimail.ru">
2
+ <img src="https://github.com/Dashamail/haskimail-ruby/raw/main/haskimail.png" alt="Haskimail Logo" title="Haskimail" width="120" height="120" align="right">
3
+ </a>
4
+
5
+ # Haskimail Ruby
6
+
7
+ Официальная Ruby-библиотека для [Haskimail HTTP API](https://haskimail.ru/developer).
8
+
9
+ [![Gem Version](https://img.shields.io/gem/v/haskimail.svg)](https://rubygems.org/gems/haskimail)
10
+ [![CI](https://github.com/Dashamail/haskimail-ruby/actions/workflows/ci.yml/badge.svg)](https://github.com/Dashamail/haskimail-ruby/actions/workflows/ci.yml)
11
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
12
+
13
+ Библиотека для [Haskimail](https://haskimail.ru) — сервиса транзакционных писем. Покрывает все 62 метода
14
+ [справочника API](https://haskimail.ru/developer): отправку писем и пакетов, шаблоны, поиск сообщений,
15
+ открытия и клики, входящие письма, возвраты, каналы, стоп-листы, вебхуки, правила для входящих,
16
+ статистику, серверы и домены, а также разбор и проверку подписи вебхуков.
17
+
18
+ - **Ноль runtime-зависимостей** — только стандартная библиотека Ruby (`net/http`, `json`, `openssl`).
19
+ - **Безопасно по умолчанию** — TLS 1.2+ с обязательной проверкой сертификата, защита от подмены
20
+ заголовков письма и path traversal, токены не попадают в логи, `inspect` и `pp`.
21
+ - **Надёжно** — повторы с экспоненциальной задержкой только там, где это безопасно: отправка письма
22
+ повторяется, лишь когда запрос не дошёл до обработки, поэтому повтор не создаёт дубль.
23
+ - **Документированные лимиты проверяются до отправки** — 50 получателей, длина темы, размер тел и
24
+ вложений, запрещённые типы файлов: ошибка понятна сразу, без сетевого запроса.
25
+ - **Удобно** — `snake_case`-параметры, ленивая пагинация через `Enumerable`, типизированные ошибки,
26
+ RBS-сигнатуры, потокобезопасные неизменяемые клиенты.
27
+ - Ruby **3.1 – 4.0**.
28
+
29
+ ## Содержание
30
+
31
+ - [Установка](#установка) · [Быстрый старт](#быстрый-старт) · [Два клиента](#два-клиента--два-ключа)
32
+ - [Параметры и ответы](#параметры-и-ответы) · [Отправка](#отправка) · [Пагинация](#пагинация)
33
+ - [Все методы](#все-методы) · [Ошибки](#ошибки) · [Повторы](#повторы) · [Настройка](#настройка)
34
+ - [Вебхуки](#вебхуки) · [Тестирование](#тестирование-интеграции) · [Разработка](#разработка) · [Ссылки](#ссылки)
35
+
36
+ ## Установка
37
+
38
+ ```ruby
39
+ # Gemfile
40
+ gem "haskimail"
41
+ ```
42
+
43
+ ## Быстрый старт
44
+
45
+ ```ruby
46
+ require "haskimail"
47
+
48
+ client = Haskimail::ServerClient.new(ENV.fetch("HASKIMAIL_SERVER_TOKEN"))
49
+
50
+ result = client.emails.deliver(
51
+ from: "Магазин <shop@example.com>",
52
+ to: "ivan@example.com",
53
+ subject: "Ваш заказ №1042 оформлен",
54
+ html_body: "<p>Здравствуйте! Заказ №1042 оформлен.</p>",
55
+ text_body: "Здравствуйте! Заказ №1042 оформлен.",
56
+ tag: "orders",
57
+ metadata: { order_id: "1042" },
58
+ message_stream: "2081"
59
+ )
60
+
61
+ result.message_id # => "6f1c0b2d9e8a4f7b3c5d2e1a0b9c8d7e"
62
+ ```
63
+
64
+ `ServerClient.new` без аргумента берёт токен из `ENV["HASKIMAIL_SERVER_TOKEN"]`,
65
+ `AccountClient.new` — из `ENV["HASKIMAIL_ACCOUNT_TOKEN"]`.
66
+
67
+ ## Два клиента — два ключа
68
+
69
+ | Клиент | Заголовок | Ресурсы |
70
+ |---|---|---|
71
+ | `Haskimail::ServerClient` | `X-Haskimail-Server-Token` | `emails`, `templates`, `messages`, `opens`, `clicks`, `inbound_messages`, `bounces`, `message_streams`, `suppressions`, `webhooks`, `inbound_rules`, `stats` |
72
+ | `Haskimail::AccountClient` | `X-Haskimail-Account-Token` | `servers`, `domains`, `templates` (+ `push`), а также по всем серверам аккаунта: `messages`, `opens`, `clicks`, `bounces`, `message_streams`, `webhooks`, `stats` |
73
+
74
+ Серверный ключ видит данные только своего сервера, ключ аккаунта — «работает со всеми серверами
75
+ аккаунта» (раздел «Обзор» документации). Явно ключ аккаунта описан для списков шаблонов, сообщений,
76
+ открытий, кликов, возвратов, каналов, вебхуков и статистики, создания шаблона, канала и вебхука и
77
+ проверки шаблона; остальные методы этих ресурсов SDK отправляет с ним по общему правилу. Отправка
78
+ писем, входящие письма, правила для входящих и стоп-листы работают только с серверным ключом.
79
+
80
+ С ключом аккаунта `templates.create` требует `associated_server_id:`, `message_streams.create` —
81
+ `server_id:` (SDK проверяет это до запроса), а вебхуку стоит явно передать `message_stream:`.
82
+
83
+ ## Параметры и ответы
84
+
85
+ Поля передаются в `snake_case` (`html_body:`, `template_id:`, `message_stream:`) — SDK сам
86
+ превращает их в имена API (`HtmlBody`, `TemplateId`, `MessageStream`). Строковые ключи уходят как
87
+ есть: `{ "HtmlBody" => "..." }`. Значения `metadata`, `template_model` и `test_render_model` — ваши
88
+ данные, их ключи не переименовываются. Поля со значением `nil` не отправляются. Строки в любой
89
+ кодировке (в том числе бинарные, как `request.raw_post`) приводятся к UTF-8; битый текст —
90
+ `Haskimail::ValidationError`.
91
+
92
+ Ответ — неизменяемый `Haskimail::ApiObject` без потерь данных:
93
+
94
+ ```ruby
95
+ domain = account.domains.get(3127)
96
+ domain.dkim_verified # snake_case
97
+ domain[:spf_text_value][:value] # то же через []
98
+ domain["DKIMVerified"] # точное имя из API
99
+ domain.to_h # исходный Hash (изменяемая копия)
100
+ Haskimail.parse_time(message.received_at) # "2026-10-07 14:30:12" (МСК) => Time
101
+ ```
102
+
103
+ Обращение через точку к отсутствующему полю бросает `NoMethodError` (опечатки не проходят молча).
104
+ Для необязательных полей используйте `[]` — он вернёт `nil`. Ключи, которые не являются
105
+ идентификаторами (например, `"Apple Mail"` в статистике почтовых клиентов), доступны через `[]`.
106
+
107
+ ## Отправка
108
+
109
+ ```ruby
110
+ client.emails.deliver(
111
+ from: "shop@example.com",
112
+ to: ["ivan@example.com", "anna@example.com"], # массив склеится в "a, b"
113
+ subject: "Счёт",
114
+ html_body: '<p>Счёт во вложении</p><img src="cid:logo">',
115
+ headers: { "X-Order-Id" => "1042" }, # или [{ name:, value: }]
116
+ attachments: [
117
+ Haskimail::Attachment.from_file("invoice.pdf"), # Base64 и MIME-тип SDK посчитает сам
118
+ Haskimail::Attachment.from_file("logo.png", content_id: "cid:logo") # встроенная картинка
119
+ ]
120
+ )
121
+
122
+ # По шаблону. Ключи модели — буквальные фрагменты шаблона, вместе со скобками
123
+ client.emails.deliver_with_template(
124
+ template_alias: "order-created",
125
+ template_model: { "{{name}}" => "Иван", "{{order}}" => "1042" },
126
+ from: "shop@example.com", to: "ivan@example.com"
127
+ )
128
+ ```
129
+
130
+ До отправки SDK проверяет [документированные ограничения](https://haskimail.ru/developer/user-guide/send-email-with-api):
131
+
132
+ - не больше 50 получателей в `To`, `Cc` и `Bcc` вместе (запятые в кавычках, комментариях `(...)` и
133
+ `<...>` адреса не разделяют);
134
+ - `From` — до 255 символов, `Subject` — до 2000, `Tag` — до 1000 (в единицах UTF-16, как считает
135
+ Haskimail);
136
+ - непустой `HtmlBody` или `TextBody`, каждый до 5 МБ; всё письмо вместе с вложениями (после Base64) —
137
+ до 10 МБ;
138
+ - вложения без запрещённых расширений (`exe`, `bat`, `scr`, `msi`…, в том числе `virus.exe. ` и `.exe`);
139
+ - для писем по шаблону — ровно одно из `template_id` и `template_alias`;
140
+ - пакет — от 1 до 500 писем и не больше 50 МБ;
141
+ - в полях-заголовках, именах и значениях заголовков, а также в имени, `ContentType` и `ContentID`
142
+ вложений нет переводов строк, управляющих символов и разделителей строк Unicode.
143
+
144
+ Проверки не обойти другим написанием имени поля: `"subject"`, `:replyTo` или `"reply_to"`
145
+ приводятся к документированному `Subject` / `ReplyTo`, а два написания одного поля в одном письме —
146
+ `ValidationError`.
147
+
148
+ ### Пакетная отправка
149
+
150
+ API отвечает `200`, даже если часть писем отклонена, поэтому пакет возвращает `BatchResult`, а не
151
+ бросает исключение:
152
+
153
+ ```ruby
154
+ batch = client.emails.deliver_batch(messages) # до 500 писем
155
+ batch = client.emails.deliver_batch_with_templates(messages)
156
+
157
+ batch.success? # все ли приняты
158
+ batch.failed.each { |r| logger.warn("#{r.to}: #{r.error_code} #{r.message}") }
159
+ batch.successful.flat_map(&:message_ids)
160
+ batch.to_json # исходные ответы API
161
+ ```
162
+
163
+ Результатов всегда столько же, сколько писем, и в том же порядке. Если API вернул другое число
164
+ результатов, SDK бросает `UnexpectedResponseError`: часть писем могла уйти, проверьте журнал
165
+ сообщений перед повторной отправкой.
166
+
167
+ ## Пагинация
168
+
169
+ `list` возвращает одну страницу, `each` — ленивый `Enumerable` по всем страницам:
170
+
171
+ ```ruby
172
+ client.messages.each(tag: "orders", from_date: Date.today - 7).first(50)
173
+ client.bounces.each(type: "HardBounce") { |bounce| unsubscribe(bounce.email) }
174
+ client.opens.each.lazy.select { |open| open.country == "RU" }.first(10)
175
+ ```
176
+
177
+ Даты принимаются как `Date`, `Time`/`DateTime` (переводятся в московское время, как ожидает API)
178
+ или строка. Документированные диапазоны проверяются до запроса: `count` от 1 до 500 у сообщений,
179
+ открытий, кликов, входящих и правил, а у сообщений, открытий и кликов ещё `count + offset` не больше
180
+ 10 000 — `each` сам останавливается на этой границе. В остальных списках `count` — положительное
181
+ целое. Размер страницы `each` по умолчанию совпадает с документацией: 50 у серверов, 500 у остальных.
182
+
183
+ ## Все методы
184
+
185
+ ```ruby
186
+ client.templates.list / each / get(id_or_alias) / create(...) / delete(id_or_alias)
187
+ client.templates.update(id, name:, subject:, html_body:, text_body:) # все 4 поля обязательны: API затирает пропущенные
188
+ client.templates.validate(subject:, html_body:, test_render_model: {...})
189
+
190
+ client.messages.list(**filters) / each / details(message_id)
191
+ client.opens.list / each / for_message(message_id)
192
+ client.clicks.list / each / for_message(message_id)
193
+ client.inbound_messages.list(status: :failed) / each / details(id) / bypass(id) / retry(id)
194
+
195
+ client.bounces.delivery_stats / list(**filters) / each
196
+ client.message_streams.list / get(id) / create(name:, message_stream_type:) / update(id, name:) /
197
+ archive(id) / unarchive(id)
198
+ client.suppressions.list(stream_id) / create(stream_id, emails) / delete(stream_id, emails)
199
+ client.webhooks.list / get(id) / create(url:, message_stream:, triggers: { delivery: true, bounce: true }) /
200
+ update(id, url:, triggers:) / delete(id)
201
+ client.inbound_rules.list / each / create("example.org") / delete(id)
202
+ client.stats.summary / sends / bounces / spam_complaints / opens / clicks / opens_by_platform /
203
+ opens_by_email_client / clicks_by_browser_family / clicks_by_platform
204
+
205
+ account = Haskimail::AccountClient.new
206
+ account.servers.list / each / get(id) / create(name:, color:, ...) / update(id, ...) / delete(id)
207
+ # color — один из 8 документированных (регистр не важен). API молча ставит purple вместо
208
+ # неизвестного цвета; SDK вместо этого бросает ValidationError, чтобы опечатка не прошла.
209
+ account.domains.list / each / get(id) / create("example.com") / delete(id) / verify_dkim(id) / verify_spf(id)
210
+ account.templates.push(source_server_id:, destination_server_id:, perform_changes: false) # пробный прогон
211
+ ```
212
+
213
+ Фильтры передаются ключевыми словами (`from_email:`, `message_stream:`, `from_date:`…); опечатка в
214
+ имени фильтра — `ValidationError` со списком допустимых. Произвольный запрос с сохранением
215
+ аутентификации, повторов и обработки ошибок:
216
+
217
+ ```ruby
218
+ client.request(:get, "/messages", query: { "count" => 10 })
219
+ ```
220
+
221
+ ## Ошибки
222
+
223
+ ```ruby
224
+ begin
225
+ client.emails.deliver(...)
226
+ rescue Haskimail::UnprocessableEntityError => e
227
+ e.error_code # => 1226
228
+ e.error_code == Haskimail::ErrorCodes::MESSAGE_STREAM_NOT_FOUND
229
+ e.error_code_description # => "Канал не найден"
230
+ rescue Haskimail::AuthenticationError # 401 — неверный ключ
231
+ rescue Haskimail::ConnectionError => e # сеть / таймаут / TLS
232
+ e.request_sent? # false — запрос точно не ушёл
233
+ rescue Haskimail::Error # всё остальное из SDK
234
+ end
235
+ ```
236
+
237
+ Некоторые ошибки в данных API отдаёт с HTTP 404, а не 422: несуществующий шаблон при `get`, канал, вебхук,
238
+ домен. Если важен код ошибки, ловите общий `Haskimail::ApiError` и проверяйте `e.error_code` —
239
+ подробнее в [вики](https://github.com/Dashamail/haskimail-ruby/wiki/Обработка-ошибок).
240
+
241
+ `Haskimail::Error` ловит любые сбои запроса, проверки параметров и разбора вебхука. Только вызов
242
+ метода Ruby с неверными аргументами (например, без обязательного ключевого слова) даёт обычный
243
+ `ArgumentError`.
244
+
245
+ ```
246
+ Haskimail::Error
247
+ ├── ConfigurationError, ValidationError, SignatureVerificationError
248
+ ├── ConnectionError ── TimeoutError, TlsError
249
+ └── ApiError (http_status, error_code, api_message, response_body)
250
+ ├── AuthenticationError 401, ForbiddenError 403, NotFoundError 404
251
+ ├── PayloadTooLargeError 413, UnprocessableEntityError 422, RateLimitError 429
252
+ ├── ServerError 5xx ── ServiceUnavailableError 503
253
+ └── UnexpectedResponseError (2xx с непонятным телом)
254
+ ```
255
+
256
+ Все коды из [справочника ошибок](https://haskimail.ru/developer/api/overview#error-codes) — в
257
+ `Haskimail::ErrorCodes`.
258
+
259
+ ## Повторы
260
+
261
+ | Ситуация | GET / PUT / DELETE | POST (отправка письма) |
262
+ |---|---|---|
263
+ | Соединение не установлено (DNS, refused, open timeout) | повтор | повтор — запрос не ушёл |
264
+ | Таймаут чтения, обрыв после отправки | повтор | **нет** — письмо могло уйти |
265
+ | 429, 503 (сервис отказал до обработки) | повтор | повтор |
266
+ | 500 / 502 / 504 | повтор | **нет** — письмо могло уйти |
267
+ | `ErrorCode 36` (временная ошибка отправки) | повтор | повтор |
268
+ | `ErrorCode 100` (внутренняя ошибка, «повторите позже») | повтор | **нет** |
269
+ | Ошибка TLS / сертификата | **нет** | **нет** |
270
+
271
+ По умолчанию 2 повтора с экспоненциальной задержкой (до 0.5 → 1 → 2 с …, с джиттером, максимум 8 с),
272
+ `Retry-After` учитывается (секунды или дата, не больше 30 с). Безопасные POST (проверка шаблона,
273
+ архивация канала, стоп-листы) повторяются как GET. `inbound_messages.bypass/retry` и
274
+ `templates.push` повторяются только как отправка письма — когда запрос точно не был выполнен
275
+ (соединение не установилось, 429/503, `ErrorCode 36`). DELETE повторяется, поэтому если первый ответ потерялся,
276
+ повтор может вернуть «не найдено» для уже удалённого объекта.
277
+
278
+ ## Настройка
279
+
280
+ ```ruby
281
+ Haskimail.configure do |config|
282
+ config.open_timeout = 5 # секунды
283
+ config.read_timeout = 30
284
+ config.write_timeout = 30
285
+ config.max_retries = 2 # 0..10
286
+ config.logger = Rails.logger # метод, путь, статус, время; без токенов и email-адресов
287
+ config.proxy = "http://user:pass@proxy.local:3128" # иначе — из окружения, см. ниже
288
+ config.ca_file = "/etc/ssl/corp-ca.pem" # доп. CA (к системным) для TLS-прокси
289
+ config.user_agent = "my-shop/2.1"
290
+ end
291
+
292
+ # Или для одного клиента:
293
+ Haskimail::ServerClient.new(token, read_timeout: 10, max_retries: 0)
294
+ ```
295
+
296
+ Без `config.proxy` прокси берётся из переменной `https_proxy`, `HTTPS_PROXY` или `http_proxy`
297
+ (в этом порядке; `http_proxy` — как у самого Net::HTTP). Как и в `URI#find_proxy`, он не
298
+ используется для loopback-адресов и для хостов из `no_proxy` (имена, суффиксы доменов вида
299
+ `.example.com` или `*.example.com`, диапазоны CIDR; `*` — без прокси для всех). Адрес проверяется так же строго, как явный: `proxy.local:3128` понимается как
300
+ `http://proxy.local:3128`, а `https://` и `socks5://` — `ConfigurationError` при создании клиента,
301
+ потому что Net::HTTP передал бы пароль открытым текстом или не смог бы им воспользоваться. Заглавная
302
+ `HTTP_PROXY` намеренно игнорируется (уязвимость httpoxy в CGI). `proxy: false` — прямое соединение
303
+ без прокси, переменные окружения не читаются.
304
+
305
+ `Haskimail.configure` меняет настройки атомарно (если блок упал — ничего не меняется), а
306
+ `Haskimail.configuration` возвращает замороженный объект. Клиент делает снимок настроек при создании
307
+ и дальше неизменяем — один экземпляр можно использовать из многих потоков. Отключить проверку
308
+ TLS-сертификата нельзя намеренно. Объект настроек нельзя сохранить через `Marshal` (в нём может быть
309
+ пароль прокси), а в YAML и `inspect` пароль скрыт.
310
+
311
+ ## Вебхуки
312
+
313
+ ```ruby
314
+ # Rails
315
+ def create
316
+ event = Haskimail::Webhook.construct_event(request.raw_post,
317
+ signing_key: ENV.fetch("HASKIMAIL_WEBHOOK_KEY"))
318
+ return head(:ok) if already_processed?(event.idempotency_key) # Haskimail может прислать дубль
319
+
320
+ case event.type
321
+ when :bounced then mark_bounced(event.recipient, event.bounce_category) # :hard_bounce, ...
322
+ when :complained then unsubscribe(event.recipient)
323
+ when :subscription_change then sync_suppression(event.recipient, event.suppressed?)
324
+ when :delivered, :opened, :clicked, :sent then track(event) # :sent — только сервер-песочница
325
+ end
326
+ head :ok
327
+ rescue Haskimail::SignatureVerificationError, Haskimail::ValidationError
328
+ head :forbidden # 4xx — Haskimail не будет повторять
329
+ end
330
+ ```
331
+
332
+ `event.metadata` — ваши `metadata` письма в виде Hash, `event.tag`, `event.occurred_at` (`Time`),
333
+ `event.server_id`, `event.stream_id`. Регистр типа события (`complained`/`COMPLAINED`) не важен.
334
+ `event.idempotency_key` для кликов включает ссылку, так что клики по разным ссылкам в одну секунду
335
+ не склеиваются. На событие вебхука Haskimail ждёт ответ до 10 секунд, повторяет при 5xx и не
336
+ повторяет при 4xx.
337
+
338
+ Входящие письма не подписываются — защитите URL HTTP Basic-авторизацией
339
+ (`https://user:pass@your-app.example/hooks/inbound`). Ответ на них Haskimail ждёт до 60 секунд и
340
+ повторяет при любом ответе, кроме 2xx и 403, а 403 останавливает повторы навсегда — поэтому отвечайте 403 только на чужие запросы
341
+ (полный пример — `examples/rails_webhook_controller.rb`):
342
+
343
+ ```ruby
344
+ message = Haskimail::Webhook.parse_inbound(request.raw_post)
345
+ message.mailbox_hash # "ticket-42" из адреса hash+ticket-42@inbound.haskimail.ru
346
+ message.stripped_text_reply
347
+ message.spam_score # из X-Spam-Score
348
+ message.attachments.each { |a| store(a.name, a.content) } # уже декодированы из Base64
349
+ message.inspect # кратко: MessageID, From, To, Subject и число вложений
350
+ ```
351
+
352
+ Размер тела ограничен: 1 МБ для событий и 64 МБ для входящих писем (`max_size:` меняет лимит).
353
+ Превышение — `Haskimail::WebhookPayloadTooLargeError` (подкласс `ValidationError`); для входящих
354
+ отвечайте на него 413, чтобы письмо осталось доступным для повтора.
355
+
356
+ > **Ограничение протокола.** Подпись Haskimail — `md5(recipient + message_id + ключ)`: она
357
+ > подтверждает только эти два поля, остальные (`event`, `SuppressSending`…) не подписаны. SDK
358
+ > проверяет подпись в постоянное время, использует только точные имена подписанных полей, отклоняет
359
+ > неоднозначные дубли полей и ожидает `recipient` вида `local@domain` без пробелов, а `message_id`
360
+ > из букв, цифр, `-` и `_` (как во всех примерах документации). Держите URL вебхука в секрете,
361
+ > используйте HTTPS и по возможности Basic-авторизацию. Подробнее — в [SECURITY.md](SECURITY.md).
362
+
363
+ ## Тестирование интеграции
364
+
365
+ ```ruby
366
+ # Проверка запроса без отправки: письмо не создаётся, вебхуки не срабатывают
367
+ Haskimail::ServerClient.new(Haskimail::TEST_TOKEN).emails.deliver(...)
368
+
369
+ # Тестовые адреса: письмо никуда не уходит, а событие возврата/жалобы приходит на вебхук сразу.
370
+ # Работают на живом сервере и в песочнице, не расходуют лимит (но не с TEST_TOKEN).
371
+ client.emails.deliver(from: "shop@example.com", to: Haskimail::TestAddresses::HARD_BOUNCE, ...)
372
+ Haskimail::TestAddresses::SOFT_BOUNCE / BLOCKED / SPAM_COMPLAINT
373
+
374
+ # Сервер-песочница: всё как на живом, но письма не доставляются
375
+ account.servers.create(name: "Staging", color: "yellow", delivery_type: "Sandbox")
376
+ ```
377
+
378
+ Скрипт `examples/smoke_test.rb` проверяет SDK на живом API, ничего не отправляя и не изменяя:
379
+
380
+ ```bash
381
+ ruby -Ilib examples/smoke_test.rb
382
+ HASKIMAIL_SERVER_TOKEN=... HASKIMAIL_ACCOUNT_TOKEN=... SMOKE_FROM=you@your-domain.example \
383
+ ruby -Ilib examples/smoke_test.rb
384
+ ```
385
+
386
+ ## Разработка
387
+
388
+ ```bash
389
+ bin/setup # bundle install
390
+ bundle exec rake # тесты (100% строк и веток) + RBS + Steep + RuboCop
391
+ bundle exec rake test # только тесты
392
+ bin/console # IRB с загруженным SDK
393
+ ```
394
+
395
+ Тесты не ходят в сеть: юнит-тесты используют подменный транспорт, интеграционные — настоящий
396
+ локальный HTTPS-сервер с самоподписанным сертификатом (TLS, таймауты, gzip, повторы, многопоточность).
397
+ Отдельно есть контрактный тест на все 62 метода справочника, тесты на обезличенных реальных ответах
398
+ API и фаззинг со свойствами (`FUZZ_SEED`, `FUZZ_ITERATIONS`). CI гоняет Ruby 3.1–4.0 с json 2.x и 3.x,
399
+ проверяет типы Steep по RBS, 100% документирования YARD и раз в неделю — свежие версии зависимостей.
400
+ Подробнее — в [CONTRIBUTING.md](https://github.com/Dashamail/haskimail-ruby/blob/main/CONTRIBUTING.md),
401
+ выпуск версий — в [RELEASING.md](https://github.com/Dashamail/haskimail-ruby/blob/main/RELEASING.md).
402
+
403
+ ## Ссылки
404
+
405
+ - [Документация API Haskimail](https://haskimail.ru/developer)
406
+ - [Wiki](https://github.com/Dashamail/haskimail-ruby/wiki) — подробное руководство с примерами
407
+ - [RubyGems](https://rubygems.org/gems/haskimail) — страница гема
408
+ - [haskimail.js](https://github.com/Dashamail/haskimail.js) — Node.js SDK
409
+ - [haskimail-python](https://github.com/Dashamail/haskimail-python) — Python SDK
410
+ - [Dashamail](https://dashamail.ru) — платформа email-маркетинга
411
+
412
+ ## Лицензия
413
+
414
+ MIT. Подробнее в файле [LICENSE](LICENSE).
data/SECURITY.md ADDED
@@ -0,0 +1,64 @@
1
+ # Безопасность
2
+
3
+ ## Сообщить об уязвимости
4
+
5
+ Пишите на support@haskimail.ru с темой «Security: haskimail-ruby». Не открывайте публичный issue.
6
+
7
+ ## Что гарантирует SDK
8
+
9
+ - **Транспорт.** Только HTTPS, TLS 1.2+, проверка сертификата и имени хоста (`VERIFY_PEER`); отключить
10
+ её нельзя. Редиректы не выполняются. `base_url` с `http://`, логином/паролем, query или fragment
11
+ отклоняется. HTTPS-прокси (`https://` в адресе прокси) запрещён, потому что Net::HTTP передал бы
12
+ пароль прокси открытым текстом; это касается и прокси из переменных окружения (`https_proxy`,
13
+ `HTTPS_PROXY`, `http_proxy`), которые SDK читает и проверяет сам. `HTTP_PROXY` игнорируется
14
+ (httpoxy).
15
+ - **Секреты.** Токены проверяются на управляющие символы (защита от CRLF-инъекции в заголовки) и не
16
+ попадают в `inspect`/`to_s` клиента, запроса (`HTTP::Request`) и конфигурации; пароль прокси
17
+ маскируется. Клиент, запрос, транспорт и настройки в YAML и JSON показывают только этот
18
+ замаскированный вид, а через `Marshal` не сериализуются вовсе. Логгер пишет только метод, путь,
19
+ статус и время — без query-строки (в ней email получателей), заголовков и тел.
20
+ - **Инъекции.** ID и алиасы в путях экранируются, `.`/`..` отклоняются. В `From`, `To`, `Cc`, `Bcc`,
21
+ `ReplyTo`, `Subject`, `Tag` и в пользовательских заголовках запрещены CR/LF, прочие управляющие
22
+ символы и Unicode-разделители строк; имена заголовков проверяются по RFC 5322. Любое другое
23
+ написание имени поля (`"subject"`, `:replyTo`, `"reply_to"`) приводится к документированному до
24
+ проверок, поэтому обойти их регистром нельзя.
25
+ - **Вложения.** Расширения, которые Haskimail отклоняет (`exe`, `bat`, `scr`, `msi`…), отсекаются до
26
+ отправки, в том числе с трюками вроде `virus.exe. `. Имя, `ContentType` и `ContentID` становятся
27
+ MIME-заголовками части письма, поэтому проверяются так же, как поля-заголовки; `ContentType` — ещё и
28
+ по формату `type/subtype`.
29
+ - **Повторы.** POST, который мог дойти до сервера, никогда не повторяется автоматически — письмо не
30
+ уйдёт дважды. POST повторяется, только если запрос точно не обработан: соединение не
31
+ установилось, ответ 429/503 или `ErrorCode 36`. «Не установилось» определяется по этапу (DNS, TCP,
32
+ CONNECT через прокси, TLS-рукопожатие), а не по классу ошибки: та же ошибка сети после установки
33
+ соединения считается «запрос мог уйти». Ошибки TLS не повторяются.
34
+ - **Кодировки.** Все строки запроса приводятся к UTF-8 до сериализации; битые последовательности
35
+ байт дают `ValidationError`, а не исключения кодировок из глубины стандартной библиотеки.
36
+ - **Ответы.** JSON разбирается `JSON.parse` (никогда не создаёт объекты по `json_class`, в отличие от
37
+ `JSON.load`), глубина ограничена (100), тело ответа — 64 МБ (защита от gzip-бомб). Неожиданный
38
+ ответ на 2xx — `UnexpectedResponseError`, а не тихие пустые данные: тело не JSON, `ErrorCode` не
39
+ число или отсутствует в ответе на отправку, или в пакете результатов меньше или больше, чем писем.
40
+
41
+ ## Вебхуки: модель угроз
42
+
43
+ Подпись события — `secret = md5(recipient + message_id + ключ_подписи)`. Это формат Haskimail, и у
44
+ него есть ограничения, о которых нужно знать:
45
+
46
+ 1. **Подписаны только `recipient` и `message_id`.** Тип события, `SuppressSending`, `custom_vars` и
47
+ прочие поля не защищены: тот, кто увидел одно подписанное событие (например, в логах), может
48
+ отправить его же с другим `event`. Не логируйте сырые вебхуки целиком.
49
+ 2. **Поля склеиваются без разделителя**, поэтому граница между ними сдвигаема: из подписи для
50
+ (`anna@example.com`, `M`) можно получить валидную пару (`anna@example.co`, `mM`). SDK требует, чтобы
51
+ `recipient` был похож на email, а `message_id` состоял из `[0-9A-Za-z_-]`, — подделать можно только
52
+ адрес, который является префиксом или продолжением настоящего, но не произвольный адрес.
53
+ 3. **MD5** устарел как криптографическая функция; для этой схемы основная защита — секретность ключа.
54
+
55
+ Что делает SDK: сравнение подписи в постоянное время (`OpenSSL.secure_compare`); только точные имена
56
+ подписанных полей (`Recipient`, `email` и прочие двойники не подменят `recipient`); отказ от событий,
57
+ где у `recipient`, `message_id`, `secret`, `event` или `email` есть двойник с другим регистром; лимит
58
+ размера (1 МБ события, 64 МБ входящие, настраивается `max_size:`); до разбора JSON — проверка, что
59
+ тело начинается с `{`, и отказ от чисел из 10 000+ цифр вне строк (разбор таких чисел занимает
60
+ десятки секунд; цифры внутри строк, например в тексте входящего письма, не мешают).
61
+
62
+ Рекомендации: HTTPS-URL вебхука, дополнительно Basic-авторизация (`https://user:pass@host/path`) или
63
+ IP allow-list, идемпотентная обработка (`event.idempotency_key`), быстрый ответ и обработка в фоне.
64
+ Входящие письма (inbound) не подписываются вовсе — для них Basic-авторизация обязательна.