notify-engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +21 -0
- data/Gemfile +5 -0
- data/LICENSE +21 -0
- data/README.md +514 -0
- data/Rakefile +8 -0
- data/lib/notify/adapters/base.rb +15 -0
- data/lib/notify/adapters/email.rb +88 -0
- data/lib/notify/config.rb +28 -0
- data/lib/notify/dispatcher.rb +239 -0
- data/lib/notify/engine.rb +35 -0
- data/lib/notify/errors.rb +25 -0
- data/lib/notify/mailer.rb +48 -0
- data/lib/notify/payload_class.rb +52 -0
- data/lib/notify/registry.rb +105 -0
- data/lib/notify/test_helper.rb +44 -0
- data/lib/notify/version.rb +5 -0
- data/lib/notify.rb +93 -0
- metadata +205 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 49f09cd818de4b46ff8276c410d3f9387e01f5452d353fac70059adbf04c56ce
|
|
4
|
+
data.tar.gz: 512d37c685c49874b7bcb47919aaad4f3ebe382a2c566cefb5474dbeb5a0948f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 14994f2a931a9475c25e553ea6ced47f0c12c3994b2045a39fa78501c78c537b80cdd1a05b330f061ecb9fb11761d247985089ba7163ed93f4fc03e42809c235
|
|
7
|
+
data.tar.gz: e79f3abe314da7fe841add18b0ec5cb9fbab2610a1775525138a45860d3dd3c5c86af2d834338078e74399d64ae608336adce4229c12a5f39ce70773bda63332
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-05-29
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Convention-over-configuration message dispatch via `Notify.message(:name, **payload)` with `Notify.configure` block for adapter and per-message configuration
|
|
13
|
+
- Filesystem-driven template discovery at `app/notify_templates/<adapter>/<name>.<ext>` with format-agnostic scanning (ERB, Haml, Slim — any registered `ActionView` handler), `_` prefix to disable templates, and `Notify.messages` / `Notify.adapters` introspection
|
|
14
|
+
- Optional payload class compute layer (`Notify::PayloadClass`) with 7-method class-level DSL (`subject`, `email_recipients`, `email_from`, `email_cc`, `email_bcc`, `tg_recipients`, `locals`), static/Proc/block support via `instance_exec`, instance method overrides, and 5-level resolution priority chain
|
|
15
|
+
- ActionMailer-backed email adapter (`Notify::Adapters::Email`) with multipart support, configurable layout and helpers, subject prefix (String or Proc), CC/BCC, per-message from override, and delivery method selection (`deliver_later` default, `deliver_now` override)
|
|
16
|
+
- Test mode with `Notify.test_mode!`, `Notify.deliveries` capture array, and `Notify::TestHelper` providing `assert_notify_dispatched`, `last_notify_delivery`, and `setup_notify_test_mode` — auto-activates in `Rails.env.test?`
|
|
17
|
+
- `ActiveSupport::Notifications` instrumentation events: `notify.message.dispatch`, `notify.adapter.deliver`, `notify.adapter.error`
|
|
18
|
+
- Structured error handling: `Notify::UnknownMessage`, `Notify::MissingRecipients`, `Notify::MissingSubject`, and `Notify::DeliveryError` with aggregated `.adapter_errors` — per-adapter error isolation ensures one failure does not block other adapters
|
|
19
|
+
- Pluggable adapter system via `Notify.register_adapter(:name, klass)` and `Notify::Adapters::Base` contract (`#deliver`, `.template_extensions`) for custom delivery channels
|
|
20
|
+
|
|
21
|
+
[0.1.0]: https://github.com/lstpsche/notify-engine-gem/releases/tag/v0.1.0
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nikita Shkoda
|
|
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,514 @@
|
|
|
1
|
+
# notify-engine
|
|
2
|
+
|
|
3
|
+
Convention-over-configuration multi-channel notification dispatch for Rails.
|
|
4
|
+
|
|
5
|
+
- Single dispatch interface: `Notify.message(:name, **payload)`
|
|
6
|
+
- Filesystem-driven adapter routing — presence of a template determines which channels fire
|
|
7
|
+
- Optional payload class compute layer for recipient resolution, subject building, and data transformation
|
|
8
|
+
- Pluggable adapters (email built-in, custom adapters via `Notify.register_adapter`)
|
|
9
|
+
- Introspection: `Notify.messages` and `Notify.adapters`
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
Add the gem to your Rails application's Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "notify-engine"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Then run:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
bundle install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
**Requires Rails 7.0+.**
|
|
26
|
+
|
|
27
|
+
## Quick Start
|
|
28
|
+
|
|
29
|
+
**1. Create a template**
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
app/notify_templates/email/stuck_pg_jobs.html.erb
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```erb
|
|
36
|
+
<h2>Stuck PgJobs Detected</h2>
|
|
37
|
+
|
|
38
|
+
<p>The following jobs are stuck:</p>
|
|
39
|
+
|
|
40
|
+
<ul>
|
|
41
|
+
<% @pg_jobs.each do |job| %>
|
|
42
|
+
<li>Job #<%= job.id %> — stuck since <%= job.updated_at %></li>
|
|
43
|
+
<% end %>
|
|
44
|
+
</ul>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**2. Configure the message**
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
# config/initializers/notify.rb
|
|
51
|
+
Notify.configure do |config|
|
|
52
|
+
config.adapters[:email] = {
|
|
53
|
+
enabled: true,
|
|
54
|
+
from: "alerts@example.com",
|
|
55
|
+
delivery_method: :deliver_later
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
config.messages[:stuck_pg_jobs] = {
|
|
59
|
+
subject: "Stuck PgJobs detected",
|
|
60
|
+
email_recipients: -> { ENV.fetch("ADMIN_EMAILS", "").split(",") }
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
**3. Dispatch**
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
Notify.message(:stuck_pg_jobs, pg_jobs: stuck_jobs)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
That's it. The email adapter picks up the template, resolves recipients and subject from config, and delivers.
|
|
72
|
+
|
|
73
|
+
## Template Directory Convention
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
app/notify_templates/
|
|
77
|
+
├── email/
|
|
78
|
+
│ ├── stuck_pg_jobs.html.erb
|
|
79
|
+
│ ├── stuck_pg_jobs.text.erb # multipart companion
|
|
80
|
+
│ ├── export_report.html.erb
|
|
81
|
+
│ └── _disabled_message.html.erb # _ prefix = excluded from registry
|
|
82
|
+
├── telegram/
|
|
83
|
+
│ └── stuck_pg_jobs.md.erb
|
|
84
|
+
├── stuck_pg_jobs.rb # optional PayloadClass
|
|
85
|
+
└── export_report.rb # optional PayloadClass
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
- Each subdirectory of `app/notify_templates/` is an **adapter name**.
|
|
89
|
+
- Files within an adapter directory are **templates**. The base filename (without format and handler extensions) is the **message name**.
|
|
90
|
+
- Files starting with `_` are excluded from the registry (disabled).
|
|
91
|
+
- Multipart templates (`.html.erb` + `.text.erb`) are de-duplicated into a single message and produce a multipart email automatically.
|
|
92
|
+
- Template discovery is **format-agnostic**: any handler registered via `ActionView::Template::Handlers` is supported (ERB, Haml, Slim, etc.).
|
|
93
|
+
- Payload class files live alongside adapter directories, not inside them.
|
|
94
|
+
|
|
95
|
+
### Introspection
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
Notify.messages
|
|
99
|
+
# => { export_report: [:email], stuck_pg_jobs: [:email, :telegram] }
|
|
100
|
+
|
|
101
|
+
Notify.adapters
|
|
102
|
+
# => { email: [:export_report, :stuck_pg_jobs], telegram: [:stuck_pg_jobs] }
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Both hashes are sorted alphabetically at all levels. For advanced use, `Notify.registry` provides direct access to the underlying `Notify::Registry` instance.
|
|
106
|
+
|
|
107
|
+
## Configuration
|
|
108
|
+
|
|
109
|
+
```ruby
|
|
110
|
+
# config/initializers/notify.rb
|
|
111
|
+
Notify.configure do |config|
|
|
112
|
+
# Where templates live (relative to Rails.root)
|
|
113
|
+
config.templates_path = "app/notify_templates" # default
|
|
114
|
+
|
|
115
|
+
# Email adapter settings
|
|
116
|
+
config.adapters[:email] = {
|
|
117
|
+
enabled: true, # default: true
|
|
118
|
+
from: "ae@novus.online", # default: nil (falls back to ActionMailer default)
|
|
119
|
+
delivery_method: :deliver_later, # default: :deliver_later
|
|
120
|
+
default_recipients: ["admin@example.com"], # default: []
|
|
121
|
+
layout: false, # default: false (or a layout name string)
|
|
122
|
+
helpers: [], # default: [] (e.g. [MailerHelper])
|
|
123
|
+
subject_prefix: -> { "[#{Rails.env.upcase}]:" } # default: nil (String or Proc)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
# Telegram adapter (architecture-ready, not yet implemented)
|
|
127
|
+
config.adapters[:telegram] = {
|
|
128
|
+
enabled: false,
|
|
129
|
+
bots: []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Per-message overrides (optional — only when you don't use a payload class)
|
|
133
|
+
config.messages[:stuck_pg_jobs] = {
|
|
134
|
+
subject: "Stuck PgJobs detected",
|
|
135
|
+
email_recipients: -> { ENV.fetch("AE_ADMINS_EMAILS", "").split(",") }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
config.messages[:export_report] = {
|
|
139
|
+
subject: "ThinkTime export report",
|
|
140
|
+
email_recipients: -> { ENV.fetch("THINKTIME_ADMIN_EMAIL", "").split(",") }
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Dispatching Messages
|
|
146
|
+
|
|
147
|
+
### Bare minimum — everything from config + template
|
|
148
|
+
|
|
149
|
+
```ruby
|
|
150
|
+
Notify.message(:stuck_pg_jobs, pg_jobs: stuck_jobs)
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
### Inline override of config defaults
|
|
154
|
+
|
|
155
|
+
```ruby
|
|
156
|
+
Notify.message(:stuck_pg_jobs,
|
|
157
|
+
pg_jobs: stuck_jobs,
|
|
158
|
+
subject: "Custom subject override",
|
|
159
|
+
email_to: ["override@example.com"]
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Payload class handles everything
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
Notify.message(:export_report, export_result: result)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Reserved payload keys
|
|
170
|
+
|
|
171
|
+
These keys control dispatch behavior and are stripped from template locals:
|
|
172
|
+
|
|
173
|
+
| Key | Controls |
|
|
174
|
+
|-----|----------|
|
|
175
|
+
| `subject:` | Email subject line |
|
|
176
|
+
| `email_to:` | Email recipients override |
|
|
177
|
+
| `email_from:` | From address override |
|
|
178
|
+
| `email_cc:` | CC recipients |
|
|
179
|
+
| `email_bcc:` | BCC recipients |
|
|
180
|
+
| `tg_bots:` | Telegram bot targets |
|
|
181
|
+
| `delivery_method:` | `:deliver_later` or `:deliver_now` |
|
|
182
|
+
|
|
183
|
+
## Payload Classes
|
|
184
|
+
|
|
185
|
+
### Location and naming
|
|
186
|
+
|
|
187
|
+
Payload classes live at `app/notify_templates/<name>.rb` with the class name `NotifyTemplates::<CamelizedName>` inheriting from `Notify::PayloadClass`.
|
|
188
|
+
|
|
189
|
+
```text
|
|
190
|
+
app/notify_templates/stuck_pg_jobs.rb → NotifyTemplates::StuckPgJobs
|
|
191
|
+
app/notify_templates/export_report.rb → NotifyTemplates::ExportReport
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Full example
|
|
195
|
+
|
|
196
|
+
```ruby
|
|
197
|
+
# app/notify_templates/stuck_pg_jobs.rb
|
|
198
|
+
class NotifyTemplates::StuckPgJobs < Notify::PayloadClass
|
|
199
|
+
subject { "#{@pg_jobs.size} stuck PgJob(s) detected" }
|
|
200
|
+
email_recipients -> { ENV.fetch("AE_ADMINS_EMAILS", "").split(",") }
|
|
201
|
+
email_cc ["manager@example.com"]
|
|
202
|
+
tg_recipients %i[alerts_bot notifier]
|
|
203
|
+
locals { { pg_jobs: @pg_jobs, detected_at: Time.current } }
|
|
204
|
+
|
|
205
|
+
def initialize(**payload)
|
|
206
|
+
@pg_jobs = payload[:pg_jobs]
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### DSL method semantics
|
|
212
|
+
|
|
213
|
+
Each DSL method accepts a static value, a Proc/Lambda, or a block:
|
|
214
|
+
|
|
215
|
+
```ruby
|
|
216
|
+
subject "Static subject" # static value
|
|
217
|
+
subject -> { "Dynamic: #{@pg_jobs.size} stuck" } # Proc — instance_exec'd at dispatch
|
|
218
|
+
subject { "Dynamic: #{@pg_jobs.size} stuck" } # block — instance_exec'd at dispatch
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
Blocks and Procs are `instance_exec`'d on the payload class instance at dispatch time (not at class load), so they have access to instance variables set in `initialize`.
|
|
222
|
+
|
|
223
|
+
### Instance method override
|
|
224
|
+
|
|
225
|
+
Define an instance method with the same name to take absolute precedence over the DSL declaration:
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
class NotifyTemplates::StuckPgJobs < Notify::PayloadClass
|
|
229
|
+
subject { "#{@pg_jobs.size} stuck PgJob(s)" }
|
|
230
|
+
|
|
231
|
+
def initialize(**payload)
|
|
232
|
+
@pg_jobs = payload[:pg_jobs]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def subject
|
|
236
|
+
prefix = @pg_jobs.size > 10 ? "URGENT" : "Warning"
|
|
237
|
+
"#{prefix}: #{@pg_jobs.size} stuck PgJob(s)"
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Available DSL methods
|
|
243
|
+
|
|
244
|
+
| Method | Description |
|
|
245
|
+
|--------|-------------|
|
|
246
|
+
| `subject` | Email subject line |
|
|
247
|
+
| `email_recipients` | To: addresses (coerced to array) |
|
|
248
|
+
| `email_from` | From: address override |
|
|
249
|
+
| `email_cc` | CC: addresses |
|
|
250
|
+
| `email_bcc` | BCC: addresses |
|
|
251
|
+
| `tg_recipients` | Telegram bot name(s) |
|
|
252
|
+
| `locals` | Template variables hash |
|
|
253
|
+
|
|
254
|
+
### Resolution priority
|
|
255
|
+
|
|
256
|
+
When resolving `subject`, `recipients`, and `locals`, the first non-nil value wins:
|
|
257
|
+
|
|
258
|
+
1. **Payload class instance method** — runtime logic
|
|
259
|
+
2. **Payload class DSL declaration** — `instance_exec`'d at dispatch
|
|
260
|
+
3. **Inline kwargs** in `Notify.message()` call
|
|
261
|
+
4. **Per-message config** in initializer
|
|
262
|
+
5. **Global adapter defaults** in initializer
|
|
263
|
+
|
|
264
|
+
### When no payload class exists
|
|
265
|
+
|
|
266
|
+
- `locals` = raw `**payload` minus reserved keys
|
|
267
|
+
- Recipients and subject come from inline kwargs or initializer config
|
|
268
|
+
- If neither provides recipients → raises `Notify::MissingRecipients`
|
|
269
|
+
- If neither provides a subject (email) → raises `Notify::MissingSubject`
|
|
270
|
+
|
|
271
|
+
## Email Adapter
|
|
272
|
+
|
|
273
|
+
### Template rendering
|
|
274
|
+
|
|
275
|
+
Templates at `app/notify_templates/email/<name>.<format>.<handler>` are rendered by an internal ActionMailer subclass. Template locals are set as instance variables (`@var`) on the mailer, following ActionMailer convention.
|
|
276
|
+
|
|
277
|
+
### Multipart emails
|
|
278
|
+
|
|
279
|
+
If both `.html.erb` and `.text.erb` exist for the same message, ActionMailer automatically produces a multipart email.
|
|
280
|
+
|
|
281
|
+
### Subject prefix
|
|
282
|
+
|
|
283
|
+
`config.adapters[:email][:subject_prefix]` accepts a String or Proc, prepended to all email subjects:
|
|
284
|
+
|
|
285
|
+
```ruby
|
|
286
|
+
config.adapters[:email][:subject_prefix] = -> { "[#{Rails.env.upcase}]:" }
|
|
287
|
+
# Subject "Stuck PgJobs" becomes "[PRODUCTION]: Stuck PgJobs"
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
When `nil` or empty, no prefix is applied.
|
|
291
|
+
|
|
292
|
+
### Layout
|
|
293
|
+
|
|
294
|
+
`config.adapters[:email][:layout]` defaults to `false` (no layout). Set to a layout name string to wrap templates in a layout from `app/views/layouts/`:
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
config.adapters[:email][:layout] = "notify_mailer"
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
### Helpers
|
|
301
|
+
|
|
302
|
+
`config.adapters[:email][:helpers]` — array of helper modules available in templates:
|
|
303
|
+
|
|
304
|
+
```ruby
|
|
305
|
+
config.adapters[:email][:helpers] = [MailerHelper]
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### CC/BCC
|
|
309
|
+
|
|
310
|
+
Set via payload class DSL (`email_cc`, `email_bcc`), per-message config, or inline kwargs:
|
|
311
|
+
|
|
312
|
+
```ruby
|
|
313
|
+
Notify.message(:stuck_pg_jobs, pg_jobs: jobs, email_cc: ["manager@example.com"])
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
### From override
|
|
317
|
+
|
|
318
|
+
Resolution: payload class `email_from` → per-message config → adapter config `from:` → ActionMailer default.
|
|
319
|
+
|
|
320
|
+
### Delivery method
|
|
321
|
+
|
|
322
|
+
Defaults to `:deliver_later`. Override per-adapter, per-message, or inline:
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
# Per-message config
|
|
326
|
+
config.messages[:stuck_pg_jobs] = {
|
|
327
|
+
subject: "Stuck PgJobs",
|
|
328
|
+
email_recipients: ["admin@example.com"],
|
|
329
|
+
delivery_method: :deliver_now
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
# Inline
|
|
333
|
+
Notify.message(:stuck_pg_jobs, pg_jobs: jobs, delivery_method: :deliver_now)
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Recipient arrayification
|
|
337
|
+
|
|
338
|
+
All recipient values are coerced to arrays — passing a single string works fine.
|
|
339
|
+
|
|
340
|
+
## Test Mode
|
|
341
|
+
|
|
342
|
+
### Activation
|
|
343
|
+
|
|
344
|
+
Test mode auto-activates in `Rails.env.test?` via an engine initializer. For manual activation:
|
|
345
|
+
|
|
346
|
+
```ruby
|
|
347
|
+
Notify.test_mode!
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
Check status with `Notify.test_mode?`. Deactivate with `Notify.reset_test_mode!`.
|
|
351
|
+
|
|
352
|
+
### Captured deliveries
|
|
353
|
+
|
|
354
|
+
When test mode is active, `Notify.message` captures dispatches to `Notify.deliveries` instead of delivering. Each entry is a hash:
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
{
|
|
358
|
+
name: :stuck_pg_jobs,
|
|
359
|
+
adapters: [:email],
|
|
360
|
+
payload: { pg_jobs: [...] },
|
|
361
|
+
resolved: {
|
|
362
|
+
subject: "3 stuck PgJob(s) detected",
|
|
363
|
+
recipients: { email: ["admin@example.com"] },
|
|
364
|
+
locals: { pg_jobs: [...] }
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The `recipients` hash is keyed by adapter symbol because recipient formats differ across adapters.
|
|
370
|
+
|
|
371
|
+
### RSpec setup
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
# spec/support/notify.rb (or spec/rails_helper.rb)
|
|
375
|
+
RSpec.configure do |config|
|
|
376
|
+
config.include Notify::TestHelper
|
|
377
|
+
config.before(:each) { Notify.deliveries.clear }
|
|
378
|
+
end
|
|
379
|
+
```
|
|
380
|
+
|
|
381
|
+
### Assertion methods
|
|
382
|
+
|
|
383
|
+
| Method | Description |
|
|
384
|
+
|--------|-------------|
|
|
385
|
+
| `assert_notify_dispatched(:name, count:, to:)` | Verify a message was dispatched (optionally check count and recipients) |
|
|
386
|
+
| `last_notify_delivery` | Shorthand for `Notify.deliveries.last` |
|
|
387
|
+
| `setup_notify_test_mode` | Activate test mode and clear deliveries |
|
|
388
|
+
|
|
389
|
+
### Full test example
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
RSpec.describe "Stuck PgJobs notification" do
|
|
393
|
+
it "dispatches to admin emails" do
|
|
394
|
+
Notify.message(:stuck_pg_jobs, pg_jobs: [double(id: 1, updated_at: Time.current)])
|
|
395
|
+
|
|
396
|
+
assert_notify_dispatched(:stuck_pg_jobs, count: 1)
|
|
397
|
+
assert_notify_dispatched(:stuck_pg_jobs, to: ["admin@example.com"])
|
|
398
|
+
|
|
399
|
+
delivery = last_notify_delivery
|
|
400
|
+
expect(delivery[:resolved][:subject]).to include("stuck")
|
|
401
|
+
expect(delivery[:resolved][:locals][:pg_jobs]).to be_present
|
|
402
|
+
end
|
|
403
|
+
end
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
## Instrumentation
|
|
407
|
+
|
|
408
|
+
The dispatcher emits `ActiveSupport::Notifications` events:
|
|
409
|
+
|
|
410
|
+
| Event | Payload | When |
|
|
411
|
+
|-------|---------|------|
|
|
412
|
+
| `notify.message.dispatch` | `{ message_name:, adapters: }` | On dispatch entry |
|
|
413
|
+
| `notify.adapter.deliver` | `{ message_name:, adapter:, to: }` | Per-adapter delivery |
|
|
414
|
+
| `notify.adapter.error` | `{ message_name:, adapter:, error: }` | On adapter failure |
|
|
415
|
+
|
|
416
|
+
### Subscriber example
|
|
417
|
+
|
|
418
|
+
```ruby
|
|
419
|
+
ActiveSupport::Notifications.subscribe("notify.message.dispatch") do |*args|
|
|
420
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
421
|
+
Rails.logger.info(
|
|
422
|
+
"[Notify] Dispatched #{event.payload[:message_name]} to #{event.payload[:adapters]}"
|
|
423
|
+
)
|
|
424
|
+
end
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
## Custom Adapters
|
|
428
|
+
|
|
429
|
+
### Adapter contract
|
|
430
|
+
|
|
431
|
+
Custom adapters inherit from `Notify::Adapters::Base` and implement two methods:
|
|
432
|
+
|
|
433
|
+
```ruby
|
|
434
|
+
class MySlackAdapter < Notify::Adapters::Base
|
|
435
|
+
def deliver(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
436
|
+
# Deliver the notification via your channel.
|
|
437
|
+
# `to` is the resolved recipients array.
|
|
438
|
+
# `locals` is the resolved template variables hash.
|
|
439
|
+
# `template_path` is the Pathname to the adapter's template directory.
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
def self.template_extensions
|
|
443
|
+
# Return an array of file extensions this adapter recognizes.
|
|
444
|
+
# Used by the registry to discover templates.
|
|
445
|
+
%i[text.erb md.erb]
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
### Registration
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
# config/initializers/notify.rb
|
|
454
|
+
Notify.register_adapter(:slack, MySlackAdapter)
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
Once registered, drop templates in `app/notify_templates/slack/` and they auto-route on dispatch.
|
|
458
|
+
|
|
459
|
+
## Error Handling
|
|
460
|
+
|
|
461
|
+
| Error | Raised when |
|
|
462
|
+
|-------|-------------|
|
|
463
|
+
| `Notify::UnknownMessage` | `Notify.message(:nonexistent)` — no templates found for this name |
|
|
464
|
+
| `Notify::MissingRecipients` | No recipients resolvable for an adapter |
|
|
465
|
+
| `Notify::MissingSubject` | No subject resolvable for the email adapter |
|
|
466
|
+
| `Notify::DeliveryError` | ALL adapters fail (access individual errors via `.adapter_errors`) |
|
|
467
|
+
|
|
468
|
+
### Adapter error isolation
|
|
469
|
+
|
|
470
|
+
When multiple adapters handle a message, one adapter failing does not block the others. Only if **all** adapters fail does `Notify::DeliveryError` propagate to the caller.
|
|
471
|
+
|
|
472
|
+
### Payload class initialization errors
|
|
473
|
+
|
|
474
|
+
If a payload class raises during `initialize` or any resolution method, the error is caught and logged. The dispatcher falls back to raw payload mode for that adapter.
|
|
475
|
+
|
|
476
|
+
## Out of Scope (MVP)
|
|
477
|
+
|
|
478
|
+
The following are explicitly not part of the 0.1.0 release:
|
|
479
|
+
|
|
480
|
+
- Telegram Bot API delivery (architecture supports it, implementation deferred)
|
|
481
|
+
- Delivery tracking / persistence
|
|
482
|
+
- User preferences / opt-out
|
|
483
|
+
- Template previews (à la `ActionMailer::Preview`)
|
|
484
|
+
- Retry logic (delegated to ActiveJob/Sidekiq via `deliver_later`)
|
|
485
|
+
- Admin UI
|
|
486
|
+
- I18n locale switching
|
|
487
|
+
- Attachments
|
|
488
|
+
- Rate limiting / deduplication
|
|
489
|
+
- Migration tooling (manual conversion required)
|
|
490
|
+
- Rails generators (`rails g notify:template`)
|
|
491
|
+
|
|
492
|
+
## Development
|
|
493
|
+
|
|
494
|
+
```sh
|
|
495
|
+
git clone https://github.com/lstpsche/notify-engine-gem.git
|
|
496
|
+
cd notify-engine-gem
|
|
497
|
+
bundle install
|
|
498
|
+
```
|
|
499
|
+
|
|
500
|
+
Run tests:
|
|
501
|
+
|
|
502
|
+
```sh
|
|
503
|
+
bundle exec rspec
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
Build the gem:
|
|
507
|
+
|
|
508
|
+
```sh
|
|
509
|
+
gem build notify-engine.gemspec
|
|
510
|
+
```
|
|
511
|
+
|
|
512
|
+
## License
|
|
513
|
+
|
|
514
|
+
MIT — see [LICENSE](LICENSE).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Notify
|
|
4
|
+
module Adapters
|
|
5
|
+
class Base
|
|
6
|
+
def deliver(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
7
|
+
raise Notify::NotImplementedError, "#{self.class}#deliver must be implemented"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.template_extensions
|
|
11
|
+
raise Notify::NotImplementedError, "#{name || self}.template_extensions must be implemented"
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "action_view"
|
|
4
|
+
|
|
5
|
+
module Notify
|
|
6
|
+
module Adapters
|
|
7
|
+
class Email < Base
|
|
8
|
+
def deliver(message_name:, to:, subject:, locals:, template_path:, options: {})
|
|
9
|
+
subject = apply_subject_prefix(subject)
|
|
10
|
+
from = options[:from]
|
|
11
|
+
cc = options[:cc]
|
|
12
|
+
bcc = options[:bcc]
|
|
13
|
+
delivery_method = options[:delivery_method] || email_config[:delivery_method] || :deliver_later
|
|
14
|
+
|
|
15
|
+
configure_mailer!
|
|
16
|
+
|
|
17
|
+
message = Notify::Mailer.dispatch(
|
|
18
|
+
message_name,
|
|
19
|
+
locals: locals,
|
|
20
|
+
to: Array(to),
|
|
21
|
+
subject: subject,
|
|
22
|
+
from: from,
|
|
23
|
+
cc: cc,
|
|
24
|
+
bcc: bcc,
|
|
25
|
+
template_path: template_path.to_s
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
case delivery_method.to_sym
|
|
29
|
+
when :deliver_now
|
|
30
|
+
message.deliver_now
|
|
31
|
+
else
|
|
32
|
+
message.deliver_later
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.template_extensions
|
|
37
|
+
formats = begin
|
|
38
|
+
ActionView::Template::Types.symbols
|
|
39
|
+
rescue StandardError
|
|
40
|
+
%i[html text]
|
|
41
|
+
end
|
|
42
|
+
handlers = ActionView::Template::Handlers.extensions
|
|
43
|
+
|
|
44
|
+
extensions = []
|
|
45
|
+
formats.each do |format|
|
|
46
|
+
handlers.each do |handler|
|
|
47
|
+
extensions << :"#{format}.#{handler}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
handlers.each { |h| extensions << h }
|
|
51
|
+
|
|
52
|
+
extensions.uniq
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def email_config
|
|
58
|
+
Notify.config.adapters[:email] || {}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def apply_subject_prefix(subject)
|
|
62
|
+
prefix = email_config[:subject_prefix]
|
|
63
|
+
return subject if prefix.nil?
|
|
64
|
+
|
|
65
|
+
prefix_str = prefix.respond_to?(:call) ? prefix.call : prefix.to_s
|
|
66
|
+
return subject if prefix_str.nil? || prefix_str.to_s.strip.empty?
|
|
67
|
+
|
|
68
|
+
"#{prefix_str} #{subject}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def configure_mailer!
|
|
72
|
+
configure_layout!
|
|
73
|
+
configure_helpers!
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def configure_layout!
|
|
77
|
+
Notify::Mailer.layout(email_config[:layout] || false)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def configure_helpers!
|
|
81
|
+
helpers = email_config[:helpers]
|
|
82
|
+
return unless helpers.is_a?(Array) && helpers.any?
|
|
83
|
+
|
|
84
|
+
helpers.each { |helper_module| Notify::Mailer.helper(helper_module) }
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|