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.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ class Config
5
+ attr_accessor :templates_path
6
+ attr_reader :adapters, :messages
7
+
8
+ def initialize
9
+ @templates_path = "app/notify_templates"
10
+ @adapters = {
11
+ email: {
12
+ enabled: true,
13
+ from: nil,
14
+ delivery_method: :deliver_later,
15
+ default_recipients: [],
16
+ layout: false,
17
+ helpers: [],
18
+ subject_prefix: nil
19
+ },
20
+ telegram: {
21
+ enabled: false,
22
+ bots: []
23
+ }
24
+ }
25
+ @messages = {}
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,239 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/string/inflections"
4
+ require "active_support/notifications"
5
+
6
+ module Notify
7
+ class Dispatcher
8
+ ADAPTER_RESOLUTION = {
9
+ email: {
10
+ recipient_method: :email_recipients,
11
+ recipient_kwarg: :email_to,
12
+ config_recipients_key: :email_recipients,
13
+ default_recipients_key: :default_recipients,
14
+ requires_subject: true
15
+ },
16
+ telegram: {
17
+ recipient_method: :tg_recipients,
18
+ recipient_kwarg: :tg_bots,
19
+ config_recipients_key: :tg_recipients,
20
+ default_recipients_key: nil,
21
+ requires_subject: false
22
+ }
23
+ }.freeze
24
+
25
+ def dispatch(name, **payload)
26
+ name = name.to_sym
27
+ validate_message!(name)
28
+
29
+ payload_instance = resolve_payload_class(name, payload)
30
+ adapter_keys = resolve_enabled_adapters(name)
31
+ errors = {}
32
+
33
+ ActiveSupport::Notifications.instrument("notify.message.dispatch",
34
+ message_name: name, adapters: adapter_keys) do
35
+ adapter_keys.each do |adapter_key|
36
+ dispatch_to_adapter(adapter_key, name, payload, payload_instance, errors)
37
+ end
38
+ end
39
+
40
+ raise DeliveryError.new(errors) if errors.any? && errors.size == adapter_keys.size
41
+ end
42
+
43
+ def capture_for_test(name, **payload)
44
+ name = name.to_sym
45
+ validate_message!(name)
46
+
47
+ payload_instance = resolve_payload_class(name, payload)
48
+ adapter_keys = resolve_enabled_adapters(name)
49
+
50
+ resolved_recipients = {}
51
+ resolved_subject = nil
52
+ resolved_locals = nil
53
+ errors = {}
54
+
55
+ adapter_keys.each do |adapter_key|
56
+ resolution = adapter_resolution_config(adapter_key)
57
+ begin
58
+ recipients = resolve_recipients(adapter_key, resolution, payload_instance, payload, name)
59
+ subj = resolve_subject(adapter_key, resolution, payload_instance, payload, name)
60
+ locals = resolve_locals(payload_instance, payload)
61
+
62
+ resolved_recipients[adapter_key] = recipients
63
+ resolved_subject = subj if subj && resolved_subject.nil?
64
+ resolved_locals ||= locals
65
+ rescue => e
66
+ errors[adapter_key] = e
67
+ end
68
+ end
69
+
70
+ delivery = {
71
+ name: name,
72
+ adapters: adapter_keys,
73
+ payload: payload,
74
+ resolved: {
75
+ subject: resolved_subject,
76
+ recipients: resolved_recipients,
77
+ locals: resolved_locals || resolve_locals(payload_instance, payload)
78
+ }
79
+ }
80
+ delivery[:errors] = errors if errors.any?
81
+ delivery
82
+ end
83
+
84
+ private
85
+
86
+ def validate_message!(name)
87
+ unless Notify.registry&.message?(name)
88
+ raise UnknownMessage, "Unknown message: #{name.inspect}"
89
+ end
90
+ end
91
+
92
+ def resolve_payload_class(name, payload)
93
+ class_name = "NotifyTemplates::#{name.to_s.camelize}"
94
+ klass = class_name.safe_constantize
95
+ return nil unless klass.is_a?(Class) && klass < PayloadClass
96
+
97
+ klass.new(**payload)
98
+ rescue => e
99
+ log_warn("[Notify] PayloadClass #{class_name} failed to initialize: #{e.message}")
100
+ nil
101
+ end
102
+
103
+ def resolve_enabled_adapters(name)
104
+ registry_adapters = Notify.registry.adapters_for(name)
105
+ registered_classes = Notify.registered_adapters
106
+
107
+ registry_adapters.select do |adapter_key|
108
+ adapter_class = registered_classes[adapter_key]
109
+ unless adapter_class
110
+ log_warn("[Notify] Adapter :#{adapter_key} has templates but no registered class — skipping")
111
+ next false
112
+ end
113
+
114
+ adapter_cfg = adapter_config(adapter_key)
115
+ adapter_cfg.fetch(:enabled, true)
116
+ end
117
+ end
118
+
119
+ def dispatch_to_adapter(adapter_key, name, raw_payload, payload_instance, errors)
120
+ adapter_class = Notify.registered_adapters[adapter_key]
121
+ resolution = adapter_resolution_config(adapter_key)
122
+
123
+ recipients = resolve_recipients(adapter_key, resolution, payload_instance, raw_payload, name)
124
+ subject = resolve_subject(adapter_key, resolution, payload_instance, raw_payload, name)
125
+ locals = resolve_locals(payload_instance, raw_payload)
126
+ options = resolve_options(adapter_key, payload_instance, raw_payload, name)
127
+ template_path = Notify.registry.templates_path.join(adapter_key.to_s)
128
+
129
+ ActiveSupport::Notifications.instrument("notify.adapter.deliver",
130
+ message_name: name, adapter: adapter_key, to: recipients) do
131
+ adapter_class.new.deliver(
132
+ message_name: name,
133
+ to: recipients,
134
+ subject: subject,
135
+ locals: locals,
136
+ template_path: template_path,
137
+ options: options
138
+ )
139
+ end
140
+ rescue => e
141
+ errors[adapter_key] = e
142
+ ActiveSupport::Notifications.instrument("notify.adapter.error",
143
+ message_name: name, adapter: adapter_key, error: e)
144
+ log_error("[Notify] Adapter :#{adapter_key} failed for :#{name}: #{e.message}")
145
+ end
146
+
147
+ def adapter_resolution_config(adapter_key)
148
+ ADAPTER_RESOLUTION[adapter_key] || {
149
+ recipient_method: :"#{adapter_key}_recipients",
150
+ recipient_kwarg: :to,
151
+ config_recipients_key: :recipients,
152
+ default_recipients_key: :default_recipients,
153
+ requires_subject: false
154
+ }
155
+ end
156
+
157
+ def resolve_recipients(adapter_key, resolution, payload_instance, raw_payload, name)
158
+ recipients =
159
+ payload_instance&.resolve(resolution[:recipient_method]) ||
160
+ raw_payload[resolution[:recipient_kwarg]] ||
161
+ message_config(name)&.dig(resolution[:config_recipients_key]) ||
162
+ (resolution[:default_recipients_key] && adapter_config(adapter_key)&.dig(resolution[:default_recipients_key]))
163
+
164
+ recipients = Array(recipients)
165
+
166
+ if recipients.empty?
167
+ raise MissingRecipients, "No recipients resolvable for adapter :#{adapter_key}"
168
+ end
169
+
170
+ recipients
171
+ end
172
+
173
+ def resolve_subject(adapter_key, resolution, payload_instance, raw_payload, name)
174
+ subject =
175
+ payload_instance&.resolve(:subject) ||
176
+ raw_payload[:subject] ||
177
+ message_config(name)&.dig(:subject)
178
+
179
+ if resolution[:requires_subject] && (subject.nil? || subject.to_s.strip.empty?)
180
+ raise MissingSubject, "No subject resolvable for adapter :#{adapter_key}"
181
+ end
182
+
183
+ subject
184
+ end
185
+
186
+ def resolve_locals(payload_instance, raw_payload)
187
+ if payload_instance
188
+ payload_instance.resolve_locals
189
+ else
190
+ raw_payload.reject { |k, _| PayloadClass::RESERVED_KEYS.include?(k) }
191
+ end
192
+ end
193
+
194
+ def resolve_options(adapter_key, payload_instance, raw_payload, name)
195
+ options = {}
196
+
197
+ if adapter_key == :email
198
+ options[:from] =
199
+ payload_instance&.resolve(:email_from) ||
200
+ raw_payload[:email_from] ||
201
+ message_config(name)&.dig(:email_from) ||
202
+ adapter_config(adapter_key)&.dig(:from)
203
+
204
+ cc = payload_instance&.resolve(:email_cc) ||
205
+ raw_payload[:email_cc] ||
206
+ message_config(name)&.dig(:email_cc)
207
+ options[:cc] = cc if cc
208
+
209
+ bcc = payload_instance&.resolve(:email_bcc) ||
210
+ raw_payload[:email_bcc] ||
211
+ message_config(name)&.dig(:email_bcc)
212
+ options[:bcc] = bcc if bcc
213
+ end
214
+
215
+ options[:delivery_method] =
216
+ raw_payload[:delivery_method] ||
217
+ message_config(name)&.dig(:delivery_method) ||
218
+ adapter_config(adapter_key)&.dig(:delivery_method)
219
+
220
+ options
221
+ end
222
+
223
+ def message_config(name)
224
+ Notify.config.messages[name]
225
+ end
226
+
227
+ def adapter_config(adapter_key)
228
+ Notify.config.adapters[adapter_key] || {}
229
+ end
230
+
231
+ def log_warn(message)
232
+ Rails.logger.warn(message) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
233
+ end
234
+
235
+ def log_error(message)
236
+ Rails.logger.error(message) if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
237
+ end
238
+ end
239
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/module/delegation"
4
+ require "rails/engine"
5
+
6
+ module Notify
7
+ class Engine < ::Rails::Engine
8
+ initializer "notify_engine.autoload_paths" do |app|
9
+ templates_path = app.root.join(Notify.config.templates_path).to_s
10
+
11
+ app.config.autoload_paths << templates_path unless app.config.autoload_paths.include?(templates_path)
12
+ app.config.eager_load_paths << templates_path unless app.config.eager_load_paths.include?(templates_path)
13
+ end
14
+
15
+ initializer "notify_engine.register_adapters" do
16
+ Notify.register_adapter(:email, Notify::Adapters::Email)
17
+ end
18
+
19
+ initializer "notify_engine.registry" do |app|
20
+ app.config.to_prepare do
21
+ Notify.build_registry!(app.root.join(Notify.config.templates_path))
22
+ end
23
+ end
24
+
25
+ initializer "notify_engine.freeze_registry" do |app|
26
+ app.config.after_initialize do
27
+ Notify.registry&.freeze! if Rails.env.production?
28
+ end
29
+ end
30
+
31
+ initializer "notify_engine.test_mode", after: "notify_engine.registry" do
32
+ Notify.test_mode! if Rails.env.test?
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ class Error < StandardError; end
5
+ class UnknownMessage < Error; end
6
+ class MissingRecipients < Error; end
7
+ class MissingSubject < Error; end
8
+
9
+ class DeliveryError < Error
10
+ attr_reader :adapter_errors
11
+
12
+ def initialize(adapter_errors = {})
13
+ @adapter_errors = adapter_errors
14
+ if adapter_errors.any?
15
+ adapter_msgs = adapter_errors.map { |adapter, err| "#{adapter}: #{err.message}" }
16
+ super("All adapters failed — #{adapter_msgs.join('; ')}")
17
+ else
18
+ super("All adapters failed")
19
+ end
20
+ end
21
+ end
22
+
23
+ class AdapterNotRegistered < Error; end
24
+ class NotImplementedError < Error; end
25
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_mailer"
4
+
5
+ module Notify
6
+ class Mailer < ActionMailer::Base
7
+ def dispatch(message_name, locals:, to:, subject:, from: nil, cc: nil, bcc: nil, template_path: nil)
8
+ prepend_view_path(template_path.to_s) if template_path
9
+
10
+ bridge_locals(locals)
11
+
12
+ mail_options = {
13
+ to: to,
14
+ subject: subject,
15
+ template_name: message_name.to_s,
16
+ template_path: ""
17
+ }
18
+ mail_options[:from] = from if from
19
+ mail_options[:cc] = cc if cc
20
+ mail_options[:bcc] = bcc if bcc
21
+
22
+ mail(mail_options)
23
+ end
24
+
25
+ private
26
+
27
+ def bridge_locals(locals)
28
+ return unless locals.is_a?(Hash)
29
+
30
+ locals.each do |key, value|
31
+ key_s = key.to_s
32
+ if key_s.start_with?("_")
33
+ log_ivar_skip(key_s)
34
+ next
35
+ end
36
+ instance_variable_set(:"@#{key_s}", value)
37
+ end
38
+ end
39
+
40
+ def log_ivar_skip(key)
41
+ return unless defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
42
+
43
+ Rails.logger.warn(
44
+ "[Notify::Mailer] Skipping local '#{key}' — underscore-prefixed keys are reserved for framework internals"
45
+ )
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ class PayloadClass
5
+ RESERVED_KEYS = %i[subject email_to email_from email_cc email_bcc tg_bots delivery_method].freeze
6
+
7
+ DSL_METHODS = %i[subject email_recipients email_from email_cc email_bcc tg_recipients locals].freeze
8
+
9
+ class << self
10
+ def dsl_definitions
11
+ @_dsl_definitions ||= {}
12
+ end
13
+
14
+ DSL_METHODS.each do |method_name|
15
+ define_method(method_name) do |value = nil, &block|
16
+ if value.nil? && block.nil?
17
+ dsl_definitions[method_name]
18
+ else
19
+ dsl_definitions[method_name] = block || value
20
+ end
21
+ end
22
+ end
23
+ end
24
+
25
+ def self.new(**payload)
26
+ instance = allocate
27
+ instance.instance_variable_set(:@_raw_payload, payload.freeze)
28
+ instance.send(:initialize, **payload)
29
+ instance
30
+ end
31
+
32
+ def initialize(**payload); end
33
+
34
+ def resolve(name)
35
+ if respond_to?(name)
36
+ public_send(name)
37
+ elsif self.class.dsl_definitions.key?(name)
38
+ dsl_val = self.class.dsl_definitions[name]
39
+ dsl_val.respond_to?(:call) ? instance_exec(&dsl_val) : dsl_val
40
+ end
41
+ end
42
+
43
+ def resolve_locals
44
+ result = resolve(:locals)
45
+ result.nil? ? default_locals : result
46
+ end
47
+
48
+ def default_locals
49
+ @_raw_payload.reject { |k, _| RESERVED_KEYS.include?(k) }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_view"
4
+ require "set"
5
+
6
+ module Notify
7
+ class Registry
8
+ attr_reader :templates_path
9
+
10
+ def initialize(templates_path)
11
+ @templates_path = Pathname(templates_path)
12
+ @message_adapters = {}
13
+ scan! if @templates_path.directory?
14
+ end
15
+
16
+ def messages
17
+ @messages ||= build_messages_view
18
+ end
19
+
20
+ def adapters
21
+ @adapters_view ||= build_adapters_view
22
+ end
23
+
24
+ def message?(name)
25
+ @message_adapters.key?(name.to_sym)
26
+ end
27
+
28
+ def adapters_for(message_name)
29
+ set = @message_adapters[message_name.to_sym]
30
+ return [].freeze unless set
31
+
32
+ set.to_a.sort.freeze
33
+ end
34
+
35
+ def freeze!
36
+ @message_adapters.each_value(&:freeze)
37
+ @message_adapters.freeze
38
+ messages
39
+ adapters
40
+ self
41
+ end
42
+
43
+ def frozen?
44
+ @message_adapters.frozen?
45
+ end
46
+
47
+ private
48
+
49
+ def scan!
50
+ handler_extensions = ActionView::Template::Handlers.extensions
51
+
52
+ @templates_path.children.select(&:directory?).each do |adapter_dir|
53
+ adapter_key = adapter_dir.basename.to_s.to_sym
54
+
55
+ adapter_dir.children.select { |entry| entry.file? }.each do |template_file|
56
+ basename = template_file.basename.to_s
57
+ next if basename.start_with?("_")
58
+
59
+ message_name = extract_message_name(basename, handler_extensions)
60
+ next unless message_name
61
+
62
+ @message_adapters[message_name] ||= Set.new
63
+ @message_adapters[message_name] << adapter_key
64
+ end
65
+ end
66
+ end
67
+
68
+ def extract_message_name(filename, handler_extensions)
69
+ parts = filename.split(".")
70
+ return nil if parts.size < 2
71
+
72
+ handler = parts.last.to_sym
73
+ return nil unless handler_extensions.include?(handler)
74
+
75
+ parts.pop
76
+ parts.pop if parts.size > 1
77
+
78
+ parts.join(".").to_sym
79
+ end
80
+
81
+ def build_messages_view
82
+ result = {}
83
+ @message_adapters.keys.sort.each do |msg|
84
+ result[msg] = @message_adapters[msg].to_a.sort.freeze
85
+ end
86
+ result.freeze
87
+ end
88
+
89
+ def build_adapters_view
90
+ inverted = {}
91
+ @message_adapters.each do |msg, adapter_set|
92
+ adapter_set.each do |adapter|
93
+ inverted[adapter] ||= []
94
+ inverted[adapter] << msg
95
+ end
96
+ end
97
+
98
+ result = {}
99
+ inverted.keys.sort.each do |adapter|
100
+ result[adapter] = inverted[adapter].sort.freeze
101
+ end
102
+ result.freeze
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ module TestHelper
5
+ def assert_notify_dispatched(name, count: nil, to: nil)
6
+ name = name.to_sym
7
+ matching = Notify.deliveries.select { |d| d[:name] == name }
8
+
9
+ if matching.empty?
10
+ delivered = Notify.deliveries.map { |d| d[:name] }.inspect
11
+ raise "Expected Notify.message(:#{name}) to have been dispatched, but it was not. " \
12
+ "Deliveries: #{delivered}"
13
+ end
14
+
15
+ if count && matching.size != count
16
+ raise "Expected #{count} dispatch(es) of :#{name}, got #{matching.size}"
17
+ end
18
+
19
+ if to
20
+ expected = Array(to)
21
+ all_recipients = matching.flat_map do |d|
22
+ (d[:resolved][:recipients] || {}).values.flatten
23
+ end
24
+
25
+ missing = expected - all_recipients
26
+ unless missing.empty?
27
+ raise "Expected recipients #{expected.inspect} for :#{name}, " \
28
+ "but #{missing.inspect} not found in resolved recipients: #{all_recipients.inspect}"
29
+ end
30
+ end
31
+
32
+ true
33
+ end
34
+
35
+ def last_notify_delivery
36
+ Notify.deliveries.last
37
+ end
38
+
39
+ def setup_notify_test_mode
40
+ Notify.test_mode!
41
+ Notify.deliveries.clear
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Notify
4
+ VERSION = "0.1.0"
5
+ end
data/lib/notify.rb ADDED
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "notify/version"
4
+ require_relative "notify/errors"
5
+ require_relative "notify/config"
6
+ require_relative "notify/payload_class"
7
+ require_relative "notify/registry"
8
+ require_relative "notify/adapters/base"
9
+ require_relative "notify/mailer"
10
+ require_relative "notify/adapters/email"
11
+ require_relative "notify/dispatcher"
12
+ require_relative "notify/engine"
13
+ require_relative "notify/test_helper"
14
+
15
+ module Notify
16
+ class << self
17
+ def config
18
+ @config ||= Config.new
19
+ end
20
+
21
+ def configure
22
+ yield config
23
+ end
24
+
25
+ def reset_config!
26
+ @config = Config.new
27
+ end
28
+
29
+ def register_adapter(key, klass)
30
+ adapter_classes[key.to_sym] = klass
31
+ end
32
+
33
+ def registered_adapters
34
+ adapter_classes.dup
35
+ end
36
+
37
+ def reset_adapters!
38
+ @adapter_classes = {}
39
+ end
40
+
41
+ def registry
42
+ @registry
43
+ end
44
+
45
+ def messages
46
+ registry&.messages || {}
47
+ end
48
+
49
+ def adapters
50
+ registry&.adapters || {}
51
+ end
52
+
53
+ def build_registry!(templates_path)
54
+ @registry = Registry.new(templates_path)
55
+ end
56
+
57
+ def reset_registry!
58
+ @registry = nil
59
+ end
60
+
61
+ def test_mode!
62
+ @test_mode = true
63
+ @deliveries ||= []
64
+ end
65
+
66
+ def test_mode?
67
+ @test_mode == true
68
+ end
69
+
70
+ def deliveries
71
+ @deliveries ||= []
72
+ end
73
+
74
+ def reset_test_mode!
75
+ @test_mode = false
76
+ @deliveries&.clear
77
+ end
78
+
79
+ def message(name, **payload)
80
+ if test_mode?
81
+ deliveries << Dispatcher.new.capture_for_test(name, **payload)
82
+ else
83
+ Dispatcher.new.dispatch(name, **payload)
84
+ end
85
+ end
86
+
87
+ private
88
+
89
+ def adapter_classes
90
+ @adapter_classes ||= {}
91
+ end
92
+ end
93
+ end