notify-engine 0.1.1 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9fe9ea06039bd361b77290faf5aa69925c84239c26f21abaf669052b7b8c81a7
4
- data.tar.gz: aa981788067172e787f1eb0913f43865a87fe02be53438e87cdf369df1476729
3
+ metadata.gz: 248f96a79272360f485c3a963fbdb2fd3ae3b10008c5c394029bcb437d4b015d
4
+ data.tar.gz: 7da8f5f1d380cbfb9d1f1c0721df7bf86432bbc416df16bcd34d6c72b3e6a5ee
5
5
  SHA512:
6
- metadata.gz: 3c83ea43422748436e7d75691316f583413c6532f1c35dae6d474962d71e03d3d22542b1baf9b7c14b1030afb5eb65613f678568cd8ffa0840f13ff399bad463
7
- data.tar.gz: 01c6c9f6b5cf101846b0746a0646d374eb87eee1837b8f22d67a9c9e319f67b5b3ed66394c800b1ae9bdf638709caa33051cbf45fd9b475f38d556570dc0da29
6
+ metadata.gz: 97948a149835c21f73c3b36d868549d171d5dfc03574e8ac5d60807eda278005433cd04630fd2f670c4041322254200d951296afe350f39176b8d679675f577f
7
+ data.tar.gz: 80d26a8d7f756b92f2f49575b0845c9e114076a37a4b47d0966c8c74c9c37d3a9216f0165a04266ce46f3254bdc87224dfa5a732168b4c413eb10ebb96955590
data/CHANGELOG.md CHANGED
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.2.0] - 2026-10-23
9
+
10
+ ### Added
11
+
12
+ - Explicit single-channel `Notify.deliver` with required recipients, native JSON-safe locals, typed results and sanitized failure dispositions, preserving existing `Notify.message` behavior.
13
+ - `Notify.prepare` renders and validates without provider I/O, returning a process-local callable so callers can persist a pre-send checkpoint before delivery.
14
+ - Telegram adapter with named bots, plain-text templates, explicit chat destinations, bounded verified HTTPS requests, provider rate-delay metadata and safe access/permission errors.
15
+ - Per-message email Message-ID and SMTP timeout options; synchronous, queued, captured and suppressed outcomes remain distinct.
16
+ - Strict email delivery telemetry filters recipient and message fields. Capture mode retains explicit targets without claiming provider acceptance.
17
+ - Strict mail guards validate destinations and transport after interceptors; failures known to precede transport expose `attempted: false`. SMTP cleanup preserves the original transmission outcome.
18
+
8
19
  ## [0.1.1] - 2026-05-29
9
20
 
10
21
  ### Fixed
@@ -24,5 +35,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
24
35
  - 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
25
36
  - Pluggable adapter system via `Notify.register_adapter(:name, klass)` and `Notify::Adapters::Base` contract (`#deliver`, `.template_extensions`) for custom delivery channels
26
37
 
38
+ [0.2.0]: https://github.com/lstpsche/notify-engine-gem/releases/tag/v0.2.0
27
39
  [0.1.1]: https://github.com/lstpsche/notify-engine-gem/releases/tag/v0.1.1
28
40
  [0.1.0]: https://github.com/lstpsche/notify-engine-gem/releases/tag/v0.1.0
data/README.md CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  Convention-over-configuration multi-channel notification dispatch for Rails.
4
4
 
5
- - Single dispatch interface: `Notify.message(:name, **payload)`
5
+ - Conventional dispatch: `Notify.message(:name, **payload)`; explicit delivery outcomes: `Notify.deliver(:name, via:, to:, ...)`
6
6
  - Filesystem-driven adapter routing — presence of a template determines which channels fire
7
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`)
8
+ - Pluggable adapters (email and Telegram built-in, custom adapters via `Notify.register_adapter`)
9
9
  - Introspection: `Notify.messages` and `Notify.adapters`
10
10
 
11
11
  ## Installation
@@ -80,7 +80,7 @@ app/notify_templates/
80
80
  │ ├── export_report.html.erb
81
81
  │ └── _disabled_message.html.erb # _ prefix = excluded from registry
82
82
  ├── telegram/
83
- │ └── stuck_pg_jobs.md.erb
83
+ │ └── stuck_pg_jobs.text.erb
84
84
  ├── stuck_pg_jobs.rb # optional PayloadClass
85
85
  └── export_report.rb # optional PayloadClass
86
86
  ```
@@ -123,7 +123,7 @@ Notify.configure do |config|
123
123
  subject_prefix: -> { "[#{Rails.env.upcase}]:" } # default: nil (String or Proc)
124
124
  }
125
125
 
126
- # Telegram adapter (architecture-ready, not yet implemented)
126
+ # Telegram adapter (disabled until explicitly configured)
127
127
  config.adapters[:telegram] = {
128
128
  enabled: false,
129
129
  bots: []
@@ -473,11 +473,10 @@ When multiple adapters handle a message, one adapter failing does not block the
473
473
 
474
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
475
 
476
- ## Out of Scope (MVP)
476
+ ## Out of Scope
477
477
 
478
- The following are explicitly not part of the 0.1.0 release:
478
+ The following are outside the gem's scope:
479
479
 
480
- - Telegram Bot API delivery (architecture supports it, implementation deferred)
481
480
  - Delivery tracking / persistence
482
481
  - User preferences / opt-out
483
482
  - Template previews (à la `ActionMailer::Preview`)
@@ -512,3 +511,74 @@ gem build notify-engine.gemspec
512
511
  ## License
513
512
 
514
513
  MIT — see [LICENSE](LICENSE).
514
+
515
+ ## Explicit delivery outcomes
516
+
517
+ Use `Notify.deliver` when the caller must identify one exact destination and know what happened. It does not consult payload-class recipients, default recipients, CC/BCC overrides or per-message subject defaults. Email still uses the configured sender, layout, helpers and subject prefix.
518
+
519
+ ```ruby
520
+ result = Notify.deliver(:alert,
521
+ via: :email,
522
+ to: "operator@example.com",
523
+ subject: "Document needs attention",
524
+ locals: { body: "Review the document in the application." },
525
+ delivery_method: :deliver_now,
526
+ message_id: "stable-document-notice@example.com",
527
+ delivery_options: { open_timeout: 5, read_timeout: 15 })
528
+
529
+ result.adapter # :email
530
+ result.status # :accepted, :enqueued or :captured
531
+ result.provider_id # optional String
532
+ ```
533
+
534
+ `accepted` means the provider accepted the write. `enqueued` means only that ActionMailer accepted an asynchronous job. `captured` means test capture or a local test/file transport. Suppressed delivery and missing/disabled adapters are explicit failures. Acceptance does not prove inbox placement, human visibility or reading.
535
+
536
+ Failures raise `Notify::DeliveryFailure` with a safe `code`, `disposition` (`permanent`, `retryable` or `uncertain`), and optional `http_status`, `provider_error_code`, and `retry_after`. `attempted == false` explicitly identifies a failure known to precede transport execution; nil leaves that unspecified. No automatic retries are performed. A timeout or lost response after transmission can be uncertain and must not be blindly retried. Strict errors emit `notify.adapter.error` once; transport invocations emit `notify.adapter.deliver` without recipient/content fields. Callers own persistence, retry policy, rate coordination and reconciliation.
537
+
538
+ Strict email checks its recipient, envelope destination and transport again after Mail interceptors run. Interceptors may suppress delivery, but redirecting it, adding recipients or substituting an unsupported transport fails before provider I/O. SMTP session cleanup cannot replace the original transmission failure or undo known acceptance.
539
+
540
+ For a durable sender, prepare before recording the send boundary:
541
+
542
+ ```ruby
543
+ prepared = Notify.prepare(:alert, via: :email, to: "operator@example.com",
544
+ subject: "Document needs attention", locals: { body: "Review required." })
545
+ # Persist the application's pre-send checkpoint here.
546
+ result = prepared.call
547
+ # Persist acceptance outside the provider-error rescue boundary.
548
+ ```
549
+
550
+ Preparation renders/validates without opening a provider connection. The callable is process-local: do not serialize or enqueue it. Calling it again performs another invocation; idempotency belongs to the caller. Use native JSON-safe locals so `deliver_later` remains serializable. Custom legacy adapters need not implement the strict API.
551
+
552
+ ## Telegram
553
+
554
+ Configure named bot entries. Tokens can be callables and are evaluated when preparing a message:
555
+
556
+ ```ruby
557
+ Notify.configure do |config|
558
+ config.adapters[:telegram] = {
559
+ enabled: true,
560
+ bots: [{ name: :alerts, token: -> { ENV.fetch("ALERT_BOT_TOKEN") }, enabled: true }],
561
+ open_timeout: 5, read_timeout: 15, write_timeout: 15
562
+ }
563
+ end
564
+ ```
565
+
566
+ Create `app/notify_templates/telegram/alert.text.erb`, using ordinary template locals:
567
+
568
+ ```erb
569
+ <%= text -%>
570
+ ```
571
+
572
+ ```ruby
573
+ Notify.deliver(:alert, via: :telegram,
574
+ to: { bot: :alerts, chat_id: "@alerts_channel" },
575
+ locals: { text: "Document needs attention." })
576
+ ```
577
+
578
+ Telegram is synchronous and uses plain text, with no parse mode or link previews. Messages must fit the Bot API's 4096-character limit; the adapter rejects oversized input instead of splitting it. Bot and chat must be explicit for strict sends. Public channel/supergroup handles and numeric chat IDs are supported; personal usernames are not direct-message destinations. Channel owners must add the bot and grant posting rights. Personal recipients must first start/message the bot and then be addressed by numeric chat ID.
579
+
580
+ Legacy `Notify.message(:alert, tg_bots: [:alerts], text: "...")` can select configured bot names when each has an explicit default `chat_id` in its bot entry. A disabled bot is skipped only in legacy dispatch; unknown names are errors. Strict sends never fall back to a bot's default chat.
581
+
582
+ Telegram failures expose stable safe reasons including unavailable chat, missing membership/posting rights, blocking, invalid credentials and rate limits. Unknown provider descriptions remain generic rejections. A migrated chat ID requires configuration correction and is never silently substituted. Raw provider descriptions and credential-bearing exceptions are not retained. If the application instruments Net::HTTP, redact bot-token URL segments in its breadcrumb/transaction exporters too; the Telegram API puts the token in the request path.
583
+
584
+ `Notify.test_mode!` captures strict targets/locals in `Notify.deliveries` and returns `captured`. Disable that mode explicitly when testing actual transport behavior with a fake provider. No bot polling, inbound webhook, user-account login, database ledger or background retry engine is included.
@@ -1,29 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "action_view"
4
+ require "net/smtp"
4
5
 
5
6
  module Notify
6
7
  module Adapters
7
8
  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
9
+ # Net::SMTP#start may raise while closing a session after DATA was already accepted.
10
+ # Keep that acceptance instead of interpreting a QUIT failure as a retryable rejection.
11
+ module SMTPAcceptance
12
+ private
13
+
14
+ def start_smtp_session
15
+ accepted = false
16
+ connected = false
17
+ response = nil
18
+ transmission_failure = nil
19
+ super do |smtp|
20
+ connected = true
21
+ begin
22
+ response = yield smtp
23
+ rescue StandardError => error
24
+ transmission_failure = Email.delivery_failure(error)
25
+ raise transmission_failure, cause: nil
26
+ end
27
+ accepted = true
28
+ response
29
+ end
30
+ rescue StandardError => error
31
+ return response if accepted
14
32
 
15
- configure_mailer!
33
+ failure = transmission_failure || Email.delivery_failure(error)
34
+ if connected && failure.code == "connection_failed"
35
+ failure = DeliveryFailure.new("smtp_outcome_unknown", disposition: :uncertain)
36
+ end
37
+ raise failure, cause: nil
38
+ end
39
+ end
16
40
 
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
- )
41
+ def deliver(message_name:, to:, subject:, locals:, template_path:, options: {})
42
+ message = build_message(message_name, to, subject, locals, template_path, options)
43
+ delivery_method = options[:delivery_method] || email_config[:delivery_method] || :deliver_later
27
44
 
28
45
  case delivery_method.to_sym
29
46
  when :deliver_now
@@ -33,6 +50,51 @@ module Notify
33
50
  end
34
51
  end
35
52
 
53
+ def deliver_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
54
+ prepare_strict(message_name: message_name, to: to, subject: subject, locals: locals,
55
+ template_path: template_path, options: options).call
56
+ end
57
+
58
+ def prepare_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
59
+ options = options.merge(strict: true)
60
+ message = build_message(message_name, to, subject, locals, template_path, options)
61
+ if options[:delivery_method].to_sym == :deliver_later
62
+ return lambda do
63
+ enqueue_prepared(message)
64
+ end
65
+ end
66
+
67
+ mail = message.message
68
+ self.class.validate_transport!(mail)
69
+ -> { send_prepared(message, mail) }
70
+ rescue DeliveryFailure => error
71
+ raise error, cause: nil
72
+ rescue StandardError
73
+ raise DeliveryFailure.new("render_failed"), cause: nil
74
+ end
75
+
76
+ def send_prepared(message, mail)
77
+ message.deliver_now
78
+ raise DeliveryFailure, "delivery_suppressed" unless mail.perform_deliveries
79
+ unless mail.raise_delivery_errors
80
+ raise DeliveryFailure.new("delivery_errors_disabled", disposition: :uncertain)
81
+ end
82
+
83
+ status = mail.delivery_method.is_a?(Mail::TestMailer) || mail.delivery_method.is_a?(Mail::FileDelivery) ? :captured : :accepted
84
+ DeliveryResult.new(adapter: :email, status: status, provider_id: mail.message_id)
85
+ rescue DeliveryFailure => error
86
+ raise error, cause: nil
87
+ rescue StandardError => error
88
+ raise self.class.delivery_failure(error), cause: nil
89
+ end
90
+
91
+ def enqueue_prepared(message)
92
+ message.deliver_later
93
+ DeliveryResult.new(adapter: :email, status: :enqueued)
94
+ rescue StandardError
95
+ raise DeliveryFailure.new("enqueue_outcome_unknown", disposition: :uncertain), cause: nil
96
+ end
97
+
36
98
  def self.template_extensions
37
99
  formats = begin
38
100
  ActionView::Template::Types.symbols
@@ -52,8 +114,56 @@ module Notify
52
114
  extensions.uniq
53
115
  end
54
116
 
117
+ def self.delivery_failure(error)
118
+ if error.is_a?(Net::SMTPServerBusy)
119
+ DeliveryFailure.new("smtp_temporary_rejection", disposition: :retryable)
120
+ elsif error.is_a?(Net::SMTPFatalError) || error.is_a?(Net::SMTPAuthenticationError) || error.is_a?(Net::SMTPSyntaxError)
121
+ DeliveryFailure.new("smtp_rejected")
122
+ elsif [SocketError, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH].any? { |klass| error.is_a?(klass) }
123
+ DeliveryFailure.new("connection_failed", disposition: :retryable)
124
+ else
125
+ DeliveryFailure.new("smtp_outcome_unknown", disposition: :uncertain)
126
+ end
127
+ end
128
+
129
+ def self.validate_transport!(mail)
130
+ raise DeliveryFailure.new("delivery_suppressed", attempted: false) unless mail.perform_deliveries
131
+ raise DeliveryFailure.new("delivery_errors_disabled", attempted: false) unless mail.raise_delivery_errors
132
+ unless [Mail::SMTP, Mail::Sendmail, Mail::TestMailer, Mail::FileDelivery].any? { |klass| mail.delivery_method.is_a?(klass) }
133
+ raise DeliveryFailure.new("unsupported_email_transport", attempted: false)
134
+ end
135
+ end
136
+
55
137
  private
56
138
 
139
+ def build_message(message_name, to, subject, locals, template_path, options)
140
+ subject = apply_subject_prefix(subject)
141
+ from = options[:from]
142
+ cc = options[:cc]
143
+ bcc = options[:bcc]
144
+ mailer = options[:strict] ? Notify::StrictMailer : Notify::Mailer
145
+ configure_mailer!(mailer)
146
+
147
+ extra = {}
148
+ extra[:message_id] = options[:message_id] if options[:message_id]
149
+ extra[:delivery_options] = options[:delivery_options] if options[:delivery_options]&.any?
150
+ attributes = {
151
+ locals: locals,
152
+ to: Array(to),
153
+ subject: subject,
154
+ from: from,
155
+ cc: cc,
156
+ bcc: bcc,
157
+ template_path: template_path.to_s,
158
+ **extra
159
+ }
160
+ if options[:strict]
161
+ mailer.with(attributes).dispatch_strict(message_name)
162
+ else
163
+ mailer.dispatch(message_name, **attributes)
164
+ end
165
+ end
166
+
57
167
  def email_config
58
168
  Notify.config.adapters[:email] || {}
59
169
  end
@@ -68,20 +178,20 @@ module Notify
68
178
  "#{prefix_str} #{subject}"
69
179
  end
70
180
 
71
- def configure_mailer!
72
- configure_layout!
73
- configure_helpers!
181
+ def configure_mailer!(mailer = Notify::Mailer)
182
+ configure_layout!(mailer)
183
+ configure_helpers!(mailer)
74
184
  end
75
185
 
76
- def configure_layout!
77
- Notify::Mailer.layout(email_config[:layout] || false)
186
+ def configure_layout!(mailer = Notify::Mailer)
187
+ mailer.layout(email_config[:layout] || false)
78
188
  end
79
189
 
80
- def configure_helpers!
190
+ def configure_helpers!(mailer = Notify::Mailer)
81
191
  helpers = email_config[:helpers]
82
192
  return unless helpers.is_a?(Array) && helpers.any?
83
193
 
84
- helpers.each { |helper_module| Notify::Mailer.helper(helper_module) }
194
+ helpers.each { |helper_module| mailer.helper(helper_module) }
85
195
  end
86
196
  end
87
197
  end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ module Adapters
5
+ class Telegram < Base
6
+ def deliver(message_name:, to:, subject:, locals:, template_path:, options: {})
7
+ Array(to).each do |name|
8
+ bot = bot_config(name)
9
+ next unless bot.fetch(:enabled, true)
10
+
11
+ deliver_strict(message_name: message_name, to: { bot: name, chat_id: bot[:chat_id] },
12
+ subject: subject, locals: locals, template_path: template_path, options: options)
13
+ end
14
+ end
15
+
16
+ def deliver_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
17
+ prepare_strict(message_name: message_name, to: to, subject: subject, locals: locals,
18
+ template_path: template_path, options: options).call
19
+ end
20
+
21
+ def prepare_strict(message_name:, to:, subject:, locals:, template_path:, options: {})
22
+ bot = bot_config(to.fetch(:bot))
23
+ raise DeliveryFailure, "bot_disabled" unless bot.fetch(:enabled, true)
24
+
25
+ text = TelegramRenderer.new.render(message_name: message_name, locals: locals, template_path: template_path)
26
+ token = bot[:token]
27
+ token = token.call if token.respond_to?(:call)
28
+ TelegramClient.validate!(token: token, chat_id: to.fetch(:chat_id), text: text)
29
+ chat_id = to.fetch(:chat_id).dup
30
+ timeouts = TelegramClient.timeouts
31
+ -> { TelegramClient.new.send_message(token: token, chat_id: chat_id, text: text, timeouts: timeouts) }
32
+ rescue DeliveryFailure => error
33
+ raise error, cause: nil
34
+ rescue StandardError
35
+ raise DeliveryFailure.new("invalid_bot_configuration"), cause: nil
36
+ end
37
+
38
+ def self.template_extensions
39
+ ActionView::Template::Handlers.extensions.map { |handler| :"text.#{handler}" }
40
+ end
41
+
42
+ private
43
+
44
+ def bot_config(name)
45
+ bots = Notify.config.adapters.fetch(:telegram).fetch(:bots)
46
+ bot = bots.find { |item| item.fetch(:name).to_s == name.to_s }
47
+ raise DeliveryFailure, "unknown_bot" unless bot
48
+
49
+ bot
50
+ end
51
+ end
52
+ end
53
+ end
data/lib/notify/config.rb CHANGED
@@ -19,7 +19,10 @@ module Notify
19
19
  },
20
20
  telegram: {
21
21
  enabled: false,
22
- bots: []
22
+ bots: [],
23
+ open_timeout: 5,
24
+ read_timeout: 15,
25
+ write_timeout: 15
23
26
  }
24
27
  }
25
28
  @messages = {}
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ DeliveryResult = Struct.new(:adapter, :status, :provider_id, keyword_init: true) do
5
+ def initialize(adapter:, status:, provider_id: nil)
6
+ raise ArgumentError, "Invalid delivery status" unless %i[accepted enqueued captured].include?(status.to_sym)
7
+
8
+ super(adapter: adapter.to_sym, status: status.to_sym, provider_id: provider_id&.to_s)
9
+ freeze
10
+ end
11
+ end
12
+ end
data/lib/notify/engine.rb CHANGED
@@ -14,6 +14,7 @@ module Notify
14
14
 
15
15
  initializer "notify_engine.register_adapters" do
16
16
  Notify.register_adapter(:email, Notify::Adapters::Email)
17
+ Notify.register_adapter(:telegram, Notify::Adapters::Telegram)
17
18
  end
18
19
 
19
20
  initializer "notify_engine.registry" do |app|
data/lib/notify/errors.rb CHANGED
@@ -6,6 +6,20 @@ module Notify
6
6
  class MissingRecipients < Error; end
7
7
  class MissingSubject < Error; end
8
8
 
9
+ class DeliveryFailure < Error
10
+ attr_reader :code, :disposition, :http_status, :provider_error_code, :retry_after, :attempted
11
+
12
+ def initialize(code, disposition: :permanent, http_status: nil, provider_error_code: nil, retry_after: nil, attempted: nil)
13
+ @code = code.to_s
14
+ @disposition = disposition.to_sym
15
+ @http_status = http_status
16
+ @provider_error_code = provider_error_code
17
+ @retry_after = retry_after
18
+ @attempted = attempted
19
+ super(@code)
20
+ end
21
+ end
22
+
9
23
  class DeliveryError < Error
10
24
  attr_reader :adapter_errors
11
25
 
data/lib/notify/mailer.rb CHANGED
@@ -4,7 +4,8 @@ require "action_mailer"
4
4
 
5
5
  module Notify
6
6
  class Mailer < ActionMailer::Base
7
- def dispatch(message_name, locals:, to:, subject:, from: nil, cc: nil, bcc: nil, template_path: nil)
7
+ def dispatch(message_name, locals:, to:, subject:, from: nil, cc: nil, bcc: nil, template_path: nil,
8
+ message_id: nil, delivery_options: nil, strict: false)
8
9
  prepend_view_path(template_path.to_s) if template_path
9
10
 
10
11
  bridge_locals(locals)
@@ -16,8 +17,10 @@ module Notify
16
17
  template_path: ""
17
18
  }
18
19
  mail_options[:from] = from if from
19
- mail_options[:cc] = cc if cc
20
- mail_options[:bcc] = bcc if bcc
20
+ mail_options[:cc] = cc if strict || cc
21
+ mail_options[:bcc] = bcc if strict || bcc
22
+ mail_options[:message_id] = message_id if message_id
23
+ mail_options[:delivery_method_options] = delivery_options if delivery_options && !delivery_options.empty?
21
24
 
22
25
  mail(mail_options)
23
26
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module Notify
7
+ # A single, explicit transport invocation. Legacy payload/default resolution is intentionally separate.
8
+ class StrictDelivery
9
+ def call(name, via:, to:, subject: nil, locals: {}, delivery_method: :deliver_now, message_id: nil, delivery_options: {})
10
+ prepare(name, via: via, to: to, subject: subject, locals: locals, delivery_method: delivery_method,
11
+ message_id: message_id, delivery_options: delivery_options).call
12
+ end
13
+
14
+ # Prepare/render without provider I/O, allowing a caller to persist its pre-send checkpoint afterward.
15
+ def prepare(name, via:, to:, subject: nil, locals: {}, delivery_method: :deliver_now, message_id: nil, delivery_options: {})
16
+ fail_with("unknown_message") unless name.is_a?(String) || name.is_a?(Symbol)
17
+ fail_with("adapter_unavailable") unless via.is_a?(String) || via.is_a?(Symbol)
18
+ name = name.to_sym
19
+ adapter = via.to_sym
20
+ validate!(name, adapter, to, subject, locals, delivery_method, delivery_options)
21
+ fail_with("invalid_message_id") if message_id && adapter != :email
22
+ if message_id && !(message_id.is_a?(String) && message_id.length <= 255 && message_id.match?(/\A[^<>\s@]+@[^<>\s@]+\z/))
23
+ fail_with("invalid_message_id")
24
+ end
25
+ if Notify.test_mode?
26
+ return lambda do
27
+ Notify.deliveries << { name: name, adapters: [adapter], strict: true,
28
+ payload: { locals: locals }, resolved: { subject: subject, recipients: { adapter => [to] }, locals: locals } }
29
+ DeliveryResult.new(adapter: adapter, status: :captured)
30
+ end
31
+ end
32
+
33
+ config = Notify.config.adapters.fetch(adapter)
34
+ from = config[:from]
35
+ options = { from: from.respond_to?(:call) ? from.call : from,
36
+ delivery_method: delivery_method, message_id: message_id, delivery_options: delivery_options }
37
+ prepared = Notify.registered_adapters.fetch(adapter).new.prepare_strict(
38
+ message_name: name, to: to, subject: subject, locals: locals,
39
+ template_path: Notify.registry.templates_path.join(adapter.to_s), options: options
40
+ )
41
+ -> { execute(name, adapter, prepared) }
42
+ rescue DeliveryFailure => error
43
+ ActiveSupport::Notifications.instrument("notify.adapter.error", message_name: name, adapter: adapter, error: error)
44
+ raise error, cause: nil
45
+ rescue StandardError => original
46
+ error = DeliveryFailure.new("preparation_failed")
47
+ error.set_backtrace(original.backtrace)
48
+ ActiveSupport::Notifications.instrument("notify.adapter.error", message_name: name, adapter: adapter, error: error)
49
+ raise error, cause: nil
50
+ end
51
+
52
+ private
53
+
54
+ def execute(name, adapter, prepared)
55
+ result = ActiveSupport::Notifications.instrument("notify.adapter.deliver", message_name: name, adapter: adapter) do
56
+ prepared.call
57
+ end
58
+ unless result.is_a?(DeliveryResult) && result.adapter == adapter
59
+ raise DeliveryFailure.new("invalid_delivery_result", disposition: :uncertain)
60
+ end
61
+ result
62
+ rescue DeliveryFailure => error
63
+ ActiveSupport::Notifications.instrument("notify.adapter.error", message_name: name, adapter: adapter, error: error)
64
+ raise error, cause: nil
65
+ end
66
+
67
+ def validate!(name, adapter, to, subject, locals, delivery_method, delivery_options)
68
+ fail_with("unknown_message") unless Notify.registry&.adapters_for(name)&.include?(adapter)
69
+ klass = Notify.registered_adapters[adapter]
70
+ fail_with("adapter_unavailable") unless klass&.method_defined?(:prepare_strict)
71
+ fail_with("adapter_disabled") unless Notify.config.adapters.fetch(adapter, {}).fetch(:enabled, true)
72
+ fail_with("invalid_locals") unless locals.is_a?(Hash) && json_value?(locals)
73
+ fail_with("invalid_delivery_method") unless delivery_method.respond_to?(:to_sym) &&
74
+ %i[deliver_now deliver_later].include?(delivery_method.to_sym)
75
+ fail_with("invalid_delivery_options") unless delivery_options.is_a?(Hash) && delivery_options.all? do |key, value|
76
+ %i[open_timeout read_timeout].include?(key) && value.is_a?(Numeric) && value.finite? &&
77
+ value.respond_to?(:positive?) && value.positive?
78
+ end
79
+ if adapter == :email
80
+ fail_with("invalid_recipient") unless to.is_a?(String) && to.length <= 254 &&
81
+ to.match?(URI::MailTo::EMAIL_REGEXP) && !to.match?(/[\r\n,;]/)
82
+ fail_with("missing_subject") unless subject.is_a?(String) && !subject.strip.empty? && !subject.match?(/[\r\n]/)
83
+ elsif adapter == :telegram
84
+ fail_with("invalid_delivery_options") unless delivery_method.to_sym == :deliver_now
85
+ fail_with("invalid_recipient") unless to.is_a?(Hash) && to.size == 2 && to.key?(:bot) && to.key?(:chat_id) &&
86
+ to[:bot].respond_to?(:to_sym) && TelegramClient.valid_chat_id?(to[:chat_id])
87
+ bot = Notify.config.adapters.fetch(:telegram).fetch(:bots).find { |item| item[:name].to_s == to[:bot].to_s }
88
+ fail_with("unknown_bot") unless bot
89
+ fail_with("bot_disabled") unless bot.fetch(:enabled, true)
90
+ fail_with("invalid_delivery_options") unless delivery_options.empty?
91
+ else
92
+ fail_with("unsupported_strict_adapter")
93
+ end
94
+ end
95
+
96
+ def json_value?(value, depth = 0)
97
+ return false if depth > 20
98
+ case value
99
+ when Hash then value.all? { |key, item| (key.is_a?(String) || key.is_a?(Symbol)) && json_value?(item, depth + 1) }
100
+ when Array then value.all? { |item| json_value?(item, depth + 1) }
101
+ when String, Integer, true, false, nil then true
102
+ when Float then value.finite?
103
+ else false
104
+ end
105
+ end
106
+
107
+ def fail_with(code)
108
+ raise DeliveryFailure, code
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ class StrictMailDeliveryJob < ActionMailer::MailDeliveryJob
5
+ self.log_arguments = false
6
+ end
7
+
8
+ # Reuses the ordinary mailer while keeping strict destinations/content out of mail telemetry.
9
+ class StrictMailer < Mailer
10
+ self.delivery_job = StrictMailDeliveryJob
11
+
12
+ # Parameterized delivery keeps process.action_mailer args limited to the message name.
13
+ def dispatch_strict(message_name)
14
+ attributes = params.to_h.symbolize_keys
15
+ @_strict_recipient = attributes.fetch(:to).fetch(0).dup.freeze
16
+ message = dispatch(message_name, **attributes.merge(strict: true))
17
+ message.delivery_handler = self
18
+ message
19
+ rescue DeliveryFailure => error
20
+ raise error, cause: nil
21
+ rescue StandardError
22
+ raise DeliveryFailure.new("render_failed"), cause: nil
23
+ end
24
+
25
+ # Mail invokes its handler after interceptors and before the transport.
26
+ def deliver_mail(mail, &block)
27
+ Adapters::Email.validate_transport!(mail)
28
+ unless mail.destinations == [@_strict_recipient] && Array(mail.smtp_envelope_to) == [@_strict_recipient]
29
+ raise DeliveryFailure.new("recipient_changed", attempted: false), cause: nil
30
+ end
31
+
32
+ mail.delivery_method.extend(Adapters::Email::SMTPAcceptance) if mail.delivery_method.is_a?(Mail::SMTP)
33
+ self.class.deliver_mail(mail, &block)
34
+ end
35
+
36
+ class << self
37
+ def deliver_mail(mail)
38
+ super do
39
+ yield
40
+ rescue DeliveryFailure => error
41
+ raise error, cause: nil
42
+ rescue StandardError => error
43
+ # Sanitize before ActiveSupport adds the exception to its event and logs it.
44
+ raise Adapters::Email.delivery_failure(error), cause: nil
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def set_payload_for_mail(payload, mail)
51
+ super
52
+ %i[mail subject to from cc bcc].each { |key| payload[key] = "[FILTERED]" if payload.key?(key) }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "json"
5
+
6
+ module Notify
7
+ class TelegramClient
8
+ MAX_RESPONSE_BYTES = 65_536
9
+ REASONS = {
10
+ "Forbidden: bot is not a member of the channel chat" => "bot_not_member",
11
+ "Forbidden: bot was kicked from the channel chat" => "bot_not_member",
12
+ "Forbidden: bot was kicked from the supergroup chat" => "bot_not_member",
13
+ "Forbidden: bot was blocked by the user" => "bot_blocked",
14
+ "Bad Request: chat not found" => "chat_unavailable",
15
+ "Bad Request: not enough rights to send text messages to the chat" => "posting_forbidden",
16
+ "Bad Request: not enough rights to send text messages to the channel" => "posting_forbidden",
17
+ "Bad Request: CHAT_WRITE_FORBIDDEN" => "posting_forbidden"
18
+ }.freeze
19
+
20
+ def self.valid_chat_id?(value)
21
+ value.is_a?(String) && value.length <= 64 && value.match?(/\A(?:-?[1-9]\d*|@[a-zA-Z][a-zA-Z0-9_]{4,31})\z/)
22
+ end
23
+
24
+ def self.validate!(token:, chat_id:, text:)
25
+ unless token.is_a?(String) && token.length <= 256 && token.match?(/\A\d+:[A-Za-z0-9_-]+\z/)
26
+ raise DeliveryFailure, "invalid_bot_token"
27
+ end
28
+ raise DeliveryFailure, "invalid_recipient" unless valid_chat_id?(chat_id)
29
+ unless text.is_a?(String) && text.valid_encoding? && text.length.between?(1, 4096)
30
+ raise DeliveryFailure, "invalid_message_length"
31
+ end
32
+ end
33
+
34
+ def self.timeouts
35
+ config = Notify.config.adapters.fetch(:telegram)
36
+ { open_timeout: config.fetch(:open_timeout, 5), read_timeout: config.fetch(:read_timeout, 15),
37
+ write_timeout: config.fetch(:write_timeout, 15) }.tap do |options|
38
+ unless options.values.all? { |value| (value.is_a?(Integer) || value.is_a?(Float)) && value.finite? && value.positive? }
39
+ raise DeliveryFailure, "invalid_delivery_options"
40
+ end
41
+ end
42
+ end
43
+
44
+ def send_message(token:, chat_id:, text:, timeouts: self.class.timeouts)
45
+ transmitting = false
46
+ self.class.validate!(token: token, chat_id: chat_id, text: text)
47
+ http = Net::HTTP.new("api.telegram.org", 443)
48
+ http.use_ssl = true
49
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER
50
+ http.open_timeout = timeouts.fetch(:open_timeout)
51
+ http.read_timeout = timeouts.fetch(:read_timeout)
52
+ http.write_timeout = timeouts.fetch(:write_timeout)
53
+ http.max_retries = 0
54
+ request = Net::HTTP::Post.new("/bot#{token}/sendMessage", "Content-Type" => "application/json")
55
+ request.body = JSON.generate(chat_id: chat_id, text: text, link_preview_options: { is_disabled: true })
56
+ status = nil
57
+ body = +""
58
+ http.start do |connection|
59
+ transmitting = true
60
+ connection.request(request) do |response|
61
+ status = response.code.to_i
62
+ response.read_body do |chunk|
63
+ raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain) if body.bytesize + chunk.bytesize > MAX_RESPONSE_BYTES
64
+ body << chunk
65
+ end
66
+ end
67
+ end
68
+ result(status, body)
69
+ rescue DeliveryFailure => error
70
+ raise error, cause: nil
71
+ rescue StandardError
72
+ code = transmitting ? "telegram_outcome_unknown" : "connection_failed"
73
+ raise DeliveryFailure.new(code, disposition: transmitting ? :uncertain : :retryable), cause: nil
74
+ end
75
+
76
+ private
77
+
78
+ def result(status, body)
79
+ data = JSON.parse(body)
80
+ unless data.is_a?(Hash)
81
+ raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain, http_status: status)
82
+ end
83
+ if status.between?(200, 299) && data["ok"] == true && data["result"].is_a?(Hash) &&
84
+ data["result"]["message_id"].is_a?(Integer) && data["result"]["message_id"].positive?
85
+ return DeliveryResult.new(adapter: :telegram, status: :accepted, provider_id: data["result"]["message_id"])
86
+ end
87
+ if data["ok"] == false && data["error_code"].is_a?(Integer) && data["error_code"].between?(400, 599)
88
+ provider_code = data["error_code"]
89
+ parameters = data["parameters"].is_a?(Hash) ? data["parameters"] : {}
90
+ retry_after = parameters["retry_after"]
91
+ retry_after = nil unless retry_after.is_a?(Integer) && retry_after.positive?
92
+ code = if parameters["migrate_to_chat_id"].is_a?(Integer)
93
+ "chat_migrated"
94
+ elsif provider_code == 401
95
+ "invalid_bot_token"
96
+ elsif provider_code == 429
97
+ "rate_limited"
98
+ else
99
+ REASONS.fetch(data["description"], "telegram_rejected")
100
+ end
101
+ disposition = provider_code == 429 || provider_code >= 500 ? :retryable : :permanent
102
+ raise DeliveryFailure.new(code, disposition: disposition, http_status: status,
103
+ provider_error_code: provider_code, retry_after: retry_after)
104
+ end
105
+ raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain, http_status: status)
106
+ rescue JSON::ParserError
107
+ raise DeliveryFailure.new("telegram_outcome_unknown", disposition: :uncertain, http_status: status), cause: nil
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+
5
+ module Notify
6
+ class TelegramRenderer
7
+ def render(message_name:, locals:, template_path:)
8
+ view = ActionView::Base.with_empty_template_cache.with_view_paths([template_path.to_s], locals.stringify_keys)
9
+ view.render(template: message_name.to_s, formats: [:text], locals: locals.symbolize_keys)
10
+ rescue StandardError
11
+ raise DeliveryFailure.new("render_failed"), cause: nil
12
+ end
13
+ end
14
+ end
@@ -17,7 +17,7 @@ module Notify
17
17
  end
18
18
 
19
19
  if to
20
- expected = Array(to)
20
+ expected = to.is_a?(Hash) ? [to] : Array(to)
21
21
  all_recipients = matching.flat_map do |d|
22
22
  (d[:resolved][:recipients] || {}).values.flatten
23
23
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Notify
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/notify.rb CHANGED
@@ -2,13 +2,19 @@
2
2
 
3
3
  require_relative "notify/version"
4
4
  require_relative "notify/errors"
5
+ require_relative "notify/delivery_result"
5
6
  require_relative "notify/config"
6
7
  require_relative "notify/payload_class"
7
8
  require_relative "notify/registry"
8
9
  require_relative "notify/adapters/base"
9
10
  require_relative "notify/mailer"
11
+ require_relative "notify/strict_mailer"
10
12
  require_relative "notify/adapters/email"
13
+ require_relative "notify/telegram_client"
14
+ require_relative "notify/telegram_renderer"
15
+ require_relative "notify/adapters/telegram"
11
16
  require_relative "notify/dispatcher"
17
+ require_relative "notify/strict_delivery"
12
18
  require_relative "notify/engine"
13
19
  require_relative "notify/test_helper"
14
20
 
@@ -84,6 +90,14 @@ module Notify
84
90
  end
85
91
  end
86
92
 
93
+ def deliver(name, **options)
94
+ StrictDelivery.new.call(name, **options)
95
+ end
96
+
97
+ def prepare(name, **options)
98
+ StrictDelivery.new.prepare(name, **options)
99
+ end
100
+
87
101
  private
88
102
 
89
103
  def adapter_classes
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: notify-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nikita Shkoda
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-05-29 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: actionmailer
@@ -150,6 +149,26 @@ dependencies:
150
149
  - - "<"
151
150
  - !ruby/object:Gem::Version
152
151
  version: '9.0'
152
+ - !ruby/object:Gem::Dependency
153
+ name: webmock
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - ">="
157
+ - !ruby/object:Gem::Version
158
+ version: '3.23'
159
+ - - "<"
160
+ - !ruby/object:Gem::Version
161
+ version: '4.0'
162
+ type: :development
163
+ prerelease: false
164
+ version_requirements: !ruby/object:Gem::Requirement
165
+ requirements:
166
+ - - ">="
167
+ - !ruby/object:Gem::Version
168
+ version: '3.23'
169
+ - - "<"
170
+ - !ruby/object:Gem::Version
171
+ version: '4.0'
153
172
  description: Rails engine gem for filesystem-driven notification routing, adapter
154
173
  delivery, and optional payload computation.
155
174
  email:
@@ -166,13 +185,19 @@ files:
166
185
  - lib/notify.rb
167
186
  - lib/notify/adapters/base.rb
168
187
  - lib/notify/adapters/email.rb
188
+ - lib/notify/adapters/telegram.rb
169
189
  - lib/notify/config.rb
190
+ - lib/notify/delivery_result.rb
170
191
  - lib/notify/dispatcher.rb
171
192
  - lib/notify/engine.rb
172
193
  - lib/notify/errors.rb
173
194
  - lib/notify/mailer.rb
174
195
  - lib/notify/payload_class.rb
175
196
  - lib/notify/registry.rb
197
+ - lib/notify/strict_delivery.rb
198
+ - lib/notify/strict_mailer.rb
199
+ - lib/notify/telegram_client.rb
200
+ - lib/notify/telegram_renderer.rb
176
201
  - lib/notify/test_helper.rb
177
202
  - lib/notify/version.rb
178
203
  homepage: https://github.com/lstpsche/notify-engine-gem
@@ -183,7 +208,6 @@ metadata:
183
208
  source_code_uri: https://github.com/lstpsche/notify-engine-gem
184
209
  changelog_uri: https://github.com/lstpsche/notify-engine-gem/blob/main/CHANGELOG.md
185
210
  bug_tracker_uri: https://github.com/lstpsche/notify-engine-gem/issues
186
- post_install_message:
187
211
  rdoc_options: []
188
212
  require_paths:
189
213
  - lib
@@ -198,8 +222,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
198
222
  - !ruby/object:Gem::Version
199
223
  version: '0'
200
224
  requirements: []
201
- rubygems_version: 3.4.19
202
- signing_key:
225
+ rubygems_version: 4.0.7
203
226
  specification_version: 4
204
227
  summary: Convention-over-configuration multi-channel notifications for Rails.
205
228
  test_files: []