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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
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,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
@@ -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