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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +15 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/lib/generators/haskimail/install/install_generator.rb +32 -0
- data/lib/generators/haskimail/install/templates/haskimail.rb +27 -0
- data/lib/haskimail/attachment.rb +84 -0
- data/lib/haskimail/clients.rb +78 -0
- data/lib/haskimail/configuration.rb +89 -0
- data/lib/haskimail/connection.rb +268 -0
- data/lib/haskimail/errors.rb +130 -0
- data/lib/haskimail/paginator.rb +48 -0
- data/lib/haskimail/rails/delivery_method.rb +56 -0
- data/lib/haskimail/rails/mail_converter.rb +130 -0
- data/lib/haskimail/rails/mailer_helpers.rb +42 -0
- data/lib/haskimail/rails/message_options.rb +88 -0
- data/lib/haskimail/rails/railtie.rb +28 -0
- data/lib/haskimail/resources/account_templates.rb +53 -0
- data/lib/haskimail/resources/base.rb +75 -0
- data/lib/haskimail/resources/bounces.rb +43 -0
- data/lib/haskimail/resources/domains.rb +42 -0
- data/lib/haskimail/resources/emails.rb +149 -0
- data/lib/haskimail/resources/inbound_rules.rb +33 -0
- data/lib/haskimail/resources/message_streams.rb +46 -0
- data/lib/haskimail/resources/messages.rb +135 -0
- data/lib/haskimail/resources/servers.rb +89 -0
- data/lib/haskimail/resources/stats.rb +89 -0
- data/lib/haskimail/resources/suppressions.rb +39 -0
- data/lib/haskimail/resources/templates.rb +68 -0
- data/lib/haskimail/resources/webhooks.rb +80 -0
- data/lib/haskimail/response_handler.rb +68 -0
- data/lib/haskimail/secret.rb +40 -0
- data/lib/haskimail/serializer.rb +75 -0
- data/lib/haskimail/util.rb +110 -0
- data/lib/haskimail/version.rb +5 -0
- data/lib/haskimail/webhooks/endpoint.rb +146 -0
- data/lib/haskimail/webhooks/event.rb +103 -0
- data/lib/haskimail/webhooks/inbound_message.rb +100 -0
- data/lib/haskimail/webhooks.rb +87 -0
- data/lib/haskimail-rails.rb +19 -0
- data/lib/haskimail.rb +58 -0
- 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
|