hanami-mailer 1.3.3 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+
5
+ module Hanami
6
+ class Mailer
7
+ module DSL
8
+ # An exposure defined on a mailer.
9
+ #
10
+ # @api private
11
+ class Exposure
12
+ include Dry::Equalizer(:name, :callable, :object, :options)
13
+
14
+ attr_reader :name
15
+ attr_reader :object
16
+ attr_reader :options
17
+ attr_reader :callable
18
+
19
+ def initialize(name, proc = nil, object = nil, **options)
20
+ @name = name
21
+ @object = object
22
+ @options = options
23
+ @callable = PluckyProc.from_name(proc, name, object)
24
+ end
25
+
26
+ def bind(obj)
27
+ self.class.new(name, callable&.proc, obj, **options)
28
+ end
29
+
30
+ def proc
31
+ callable&.proc
32
+ end
33
+
34
+ def dependency_names
35
+ return [] unless callable
36
+
37
+ callable.dependency_names
38
+ end
39
+
40
+ def dependencies?
41
+ !dependency_names.empty?
42
+ end
43
+
44
+ def private?
45
+ options.fetch(:private, false)
46
+ end
47
+
48
+ def default_value
49
+ options[:default]
50
+ end
51
+
52
+ def call(input, locals = {})
53
+ if callable
54
+ call_proc(input, locals)
55
+ else
56
+ input.fetch(name) { default_value }
57
+ end
58
+ end
59
+
60
+ private
61
+
62
+ def call_proc(input, locals)
63
+ dependency_args = dependency_names.map { |name| locals.fetch(name) }
64
+ callable.call(input, *dependency_args)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tsort"
4
+ require "dry/core/equalizer"
5
+
6
+ module Hanami
7
+ class Mailer
8
+ module DSL
9
+ # @api private
10
+ class Exposures
11
+ include Dry::Equalizer(:exposures)
12
+ include TSort
13
+
14
+ attr_reader :exposures
15
+
16
+ def initialize(exposures = {})
17
+ @exposures = exposures
18
+ @has_dependencies = false
19
+ end
20
+
21
+ def initialize_copy(source)
22
+ super
23
+ @exposures = source.exposures.transform_values(&:dup)
24
+ @has_dependencies = source.instance_variable_get(:@has_dependencies)
25
+ end
26
+
27
+ def key?(name)
28
+ exposures.key?(name)
29
+ end
30
+
31
+ def [](name)
32
+ exposures[name]
33
+ end
34
+
35
+ def each(&block)
36
+ exposures.each(&block)
37
+ end
38
+
39
+ def empty?
40
+ exposures.empty?
41
+ end
42
+
43
+ def add(name, proc = nil, **options)
44
+ exposure = Exposure.new(name, proc, **options)
45
+ @has_dependencies ||= exposure.dependencies?
46
+ exposures[name] = exposure
47
+ end
48
+
49
+ def import(name, exposure)
50
+ exposures[name] = exposure.dup
51
+ end
52
+
53
+ def bind(obj)
54
+ bound_exposures = exposures.transform_values { |exposure|
55
+ exposure.bind(obj)
56
+ }
57
+
58
+ copy = self.class.new(bound_exposures)
59
+ copy.instance_variable_set(:@has_dependencies, @has_dependencies)
60
+ copy
61
+ end
62
+
63
+ # Evaluates each exposure and returns a hash of their values.
64
+ #
65
+ # By default each exposure's positional parameters resolve against its sibling exposures in
66
+ # this collection (the values accumulate as the collection is evaluated, ordered by tsort).
67
+ #
68
+ # When `dependencies` is given, positional parameters resolve against those instead, and no
69
+ # sibling resolution (or tsort) takes place. This is how the mailer collections like headers
70
+ # and delivery options consume the mailer's exposures as their one shared dependency graph.
71
+ def call(input, dependencies: nil)
72
+ ordered_evaluation_keys(dependencies).each_with_object({}) { |name, memo|
73
+ next unless (exposure = self[name])
74
+
75
+ value = exposure.(input, dependencies || memo)
76
+ value = yield(value, exposure) if block_given?
77
+
78
+ memo[name] = value
79
+ }
80
+ end
81
+
82
+ # Removes private exposures from a hash of evaluated values.
83
+ #
84
+ # Private exposures are computed and stay available as positional dependencies — to other
85
+ # exposures, and to the mailer's headers, attachments, and delivery options — but they are
86
+ # never passed to the view for rendering. This filters them out for that final step.
87
+ def reject_private(values)
88
+ values.reject { |name, _| self[name]&.private? }
89
+ end
90
+
91
+ private
92
+
93
+ # With external dependencies, there are no sibling dependencies to order, so tsort is only
94
+ # needed when resolving siblings within our own collection.
95
+ def ordered_evaluation_keys(dependencies)
96
+ dependencies.nil? && dependencies? ? tsort : exposures.keys
97
+ end
98
+
99
+ def dependencies? = @has_dependencies
100
+
101
+ def tsort_each_node(&block)
102
+ exposures.each_key(&block)
103
+ end
104
+
105
+ def tsort_each_child(name, &block)
106
+ self[name].dependency_names.each(&block) if exposures.key?(name)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ module DSL
6
+ # A plucky proc that evaluates with automatic keyword argument extraction from input.
7
+ #
8
+ # This class encapsulates the logic for calling procs/methods in a mailer context,
9
+ # handling both positional and keyword arguments intelligently based on the proc's
10
+ # signature.
11
+ #
12
+ # @api private
13
+ class PluckyProc
14
+ attr_reader :proc, :context
15
+
16
+ # Create a new plucky proc
17
+ #
18
+ # @param proc [Proc, Method, nil] the proc or method to evaluate
19
+ # @param context [Object] the object context for instance_exec
20
+ def initialize(proc, context:)
21
+ @proc = proc
22
+ @context = context
23
+ end
24
+
25
+ # Create a PluckyProc from either a proc or a method name on the context
26
+ #
27
+ # @param proc [Proc, Method, nil] the proc to use, or nil to look up a method
28
+ # @param name [Symbol] the method name to look up if proc is nil
29
+ # @param context [Object] the context object
30
+ #
31
+ # @return [PluckyProc, nil] a new PluckyProc or nil if no proc/method found
32
+ def self.from_name(proc, name, context)
33
+ resolved_proc =
34
+ if proc
35
+ proc
36
+ elsif context.respond_to?(name, _include_private = true)
37
+ context.method(name)
38
+ end
39
+
40
+ new(resolved_proc, context: context) if resolved_proc
41
+ end
42
+
43
+ # Evaluate the proc with input and optional positional arguments
44
+ #
45
+ # @param input [Hash] input hash to extract keyword arguments from
46
+ # @param args [Array] positional arguments to pass to the proc
47
+ #
48
+ # @return [Object] the result of evaluating the proc
49
+ def call(input, *args)
50
+ return nil unless proc
51
+
52
+ keywords = extract_keywords(input)
53
+
54
+ if keywords.empty?
55
+ call_without_keywords(*args)
56
+ else
57
+ call_with_keywords(keywords, *args)
58
+ end
59
+ end
60
+
61
+ # Check if this evaluator has a proc to call
62
+ #
63
+ # @return [Boolean]
64
+ def callable?
65
+ !proc.nil?
66
+ end
67
+
68
+ # Get dependency parameter names (positional args: :req, :opt)
69
+ #
70
+ # @return [Array<Symbol>] parameter names
71
+ def dependency_names
72
+ @dependency_names ||=
73
+ if proc
74
+ proc.parameters.each_with_object([]) { |(type, name), names|
75
+ names << name if %i[req opt].include?(type)
76
+ }
77
+ else
78
+ []
79
+ end
80
+ end
81
+
82
+ # Get keyword parameter names (:key, :keyreq)
83
+ #
84
+ # @return [Array<Symbol>] parameter names
85
+ def keyword_names
86
+ @keyword_names ||=
87
+ if proc
88
+ proc.parameters.each_with_object([]) { |(type, name), keys|
89
+ keys << name if %i[key keyreq].include?(type)
90
+ }
91
+ else
92
+ []
93
+ end
94
+ end
95
+
96
+ private
97
+
98
+ def call_without_keywords(*args)
99
+ if proc.is_a?(Method)
100
+ proc.call(*args)
101
+ else
102
+ context.instance_exec(*args, &proc)
103
+ end
104
+ end
105
+
106
+ def call_with_keywords(keywords, *args)
107
+ if proc.is_a?(Method)
108
+ proc.call(*args, **keywords)
109
+ else
110
+ context.instance_exec(*args, **keywords, &proc)
111
+ end
112
+ end
113
+
114
+ def extract_keywords(input)
115
+ keywords = {}
116
+ params = proc.parameters
117
+
118
+ # Extract specific keyword parameters (:key, :keyreq)
119
+ params.each do |type, name|
120
+ if %i[key keyreq].include?(type) && input.key?(name)
121
+ keywords[name] = input[name]
122
+ end
123
+ end
124
+
125
+ # Merge all input for **kwargs (:keyrest)
126
+ if params.any? { |(type, _)| type == :keyrest }
127
+ keywords.merge!(input)
128
+ end
129
+
130
+ keywords
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ # Base error class for all Hanami::Mailer errors
6
+ #
7
+ # @api public
8
+ # @since 3.0.0
9
+ class Error < StandardError
10
+ end
11
+
12
+ # Raised when a mailer is missing required delivery configuration
13
+ #
14
+ # @api public
15
+ # @since 3.0.0
16
+ class MissingDeliveryError < Error
17
+ def initialize(message = "Missing delivery method. Configure a delivery method using `config.delivery = ...`")
18
+ super
19
+ end
20
+ end
21
+
22
+ # Raised when a mailer message is missing a sender address
23
+ #
24
+ # @api public
25
+ # @since 3.0.0
26
+ class MissingSenderError < Error
27
+ def initialize(message = "Missing sender. Provide a `from` address")
28
+ super
29
+ end
30
+ end
31
+
32
+ # Raised when a mailer message is missing required recipient information
33
+ #
34
+ # @api public
35
+ # @since 3.0.0
36
+ class MissingRecipientError < Error
37
+ def initialize(message = "Missing recipient. Provide at least one of: to, cc, or bcc")
38
+ super
39
+ end
40
+ end
41
+
42
+ # Raised when a static attachment file cannot be found
43
+ #
44
+ # @api public
45
+ # @since 3.0.0
46
+ class MissingAttachmentError < Error
47
+ def initialize(filename, paths = [])
48
+ message =
49
+ if paths.any?
50
+ "Attachment file not found: #{filename}. "\
51
+ "Searched in: #{paths.join(', ')}"
52
+ else
53
+ "Attachment file not found: #{filename}. " \
54
+ "Configure `attachment_paths` to specify where attachment files are located."
55
+ end
56
+ super(message)
57
+ end
58
+ end
59
+
60
+ # Raised when duplicate attachment filenames are detected
61
+ #
62
+ # @api public
63
+ # @since 3.0.0
64
+ class DuplicateAttachmentError < Error
65
+ def initialize(filename)
66
+ super(
67
+ "Duplicate attachment filename: #{filename.inspect}. " \
68
+ "Each attachment must have a unique filename."
69
+ )
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hanami
4
+ class Mailer
5
+ # Represents an email message
6
+ #
7
+ # @api private
8
+ class Message
9
+ # @api private
10
+ attr_reader :from, :to, :cc, :bcc, :reply_to, :return_path, :subject
11
+
12
+ # @api private
13
+ attr_reader :html_body, :text_body, :attachments, :headers, :charset, :delivery_options
14
+
15
+ # Initialize a new message
16
+ #
17
+ # @param from [String, Array<String>] sender address(es)
18
+ # @param to [String, Array<String>, nil] recipient address(es)
19
+ # @param cc [String, Array<String>, nil] carbon copy address(es)
20
+ # @param bcc [String, Array<String>, nil] blind carbon copy address(es)
21
+ # @param reply_to [String, Array<String>, nil] reply-to address(es)
22
+ # @param return_path [String, Array<String>, nil] return path address(es) for bounces
23
+ # @param subject [String] email subject
24
+ # @param html_body [String, nil] HTML body content
25
+ # @param text_body [String, nil] plain text body content
26
+ # @param attachments [Array<Attachment>] array of attachments
27
+ # @param headers [Hash] additional email headers
28
+ # @param charset [String] character encoding (default: "UTF-8")
29
+ # @param delivery_options [Hash] delivery-method-specific options
30
+ #
31
+ # @api private
32
+ def initialize(from:, subject:, to: nil, cc: nil, bcc: nil, reply_to: nil, return_path: nil,
33
+ html_body: nil, text_body: nil, attachments: [], headers: {}, charset: "UTF-8",
34
+ delivery_options: {})
35
+ @from = normalize_addresses(from)
36
+ @to = normalize_addresses(to)
37
+ @cc = normalize_addresses(cc)
38
+ @bcc = normalize_addresses(bcc)
39
+ @reply_to = normalize_addresses(reply_to)
40
+ @return_path = normalize_addresses(return_path)
41
+ @subject = subject
42
+ @html_body = html_body
43
+ @text_body = text_body
44
+ @attachments = attachments
45
+ @headers = headers
46
+ @charset = charset
47
+ @delivery_options = delivery_options
48
+
49
+ validate_sender!
50
+ validate_recipients!
51
+ end
52
+
53
+ # Convert message to hash representation
54
+ #
55
+ # @return [Hash]
56
+ #
57
+ # @api private
58
+ def to_h
59
+ {
60
+ from: from,
61
+ to: to,
62
+ cc: cc,
63
+ bcc: bcc,
64
+ reply_to: reply_to,
65
+ return_path: return_path,
66
+ subject: subject,
67
+ html_body: html_body,
68
+ text_body: text_body,
69
+ attachments: attachments,
70
+ headers: headers,
71
+ charset: charset,
72
+ delivery_options: delivery_options
73
+ }
74
+ end
75
+
76
+ private
77
+
78
+ # Normalize addresses to array format
79
+ def normalize_addresses(addresses)
80
+ return nil if addresses.nil?
81
+ return addresses if addresses.is_a?(Array)
82
+
83
+ [addresses]
84
+ end
85
+
86
+ # Validate that a sender address is present
87
+ def validate_sender!
88
+ if from.nil? || from.empty?
89
+ raise MissingSenderError
90
+ end
91
+ end
92
+
93
+ # Validate that at least one recipient is present
94
+ def validate_recipients!
95
+ if (to.nil? || to.empty?) && (cc.nil? || cc.empty?) && (bcc.nil? || bcc.empty?)
96
+ raise MissingRecipientError
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -1,8 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hanami
4
- module Mailer
5
- # @since 0.1.0
6
- VERSION = "1.3.3"
4
+ class Mailer
5
+ # @api public
6
+ # @since 3.0.0
7
+ VERSION = "3.0.0"
7
8
  end
8
9
  end