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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +154 -45
- data/LICENSE +20 -0
- data/README.md +518 -303
- data/hanami-mailer.gemspec +23 -18
- data/lib/hanami/mailer/attachment.rb +133 -0
- data/lib/hanami/mailer/attachment_set.rb +38 -0
- data/lib/hanami/mailer/delivery/result.rb +101 -0
- data/lib/hanami/mailer/delivery/smtp.rb +168 -0
- data/lib/hanami/mailer/delivery/test.rb +57 -0
- data/lib/hanami/mailer/dsl/attachments.rb +108 -0
- data/lib/hanami/mailer/dsl/exposure.rb +69 -0
- data/lib/hanami/mailer/dsl/exposures.rb +111 -0
- data/lib/hanami/mailer/dsl/plucky_proc.rb +135 -0
- data/lib/hanami/mailer/errors.rb +73 -0
- data/lib/hanami/mailer/message.rb +101 -0
- data/lib/hanami/mailer/version.rb +4 -3
- data/lib/hanami/mailer/view_integration.rb +205 -0
- data/lib/hanami/mailer.rb +372 -270
- data/lib/hanami-mailer.rb +1 -1
- metadata +40 -97
- data/LICENSE.md +0 -22
- data/lib/hanami/mailer/configuration.rb +0 -310
- data/lib/hanami/mailer/dsl.rb +0 -628
- data/lib/hanami/mailer/rendering/template_name.rb +0 -55
- data/lib/hanami/mailer/rendering/templates_finder.rb +0 -135
- data/lib/hanami/mailer/template.rb +0 -42
|
@@ -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
|