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
data/lib/hanami/mailer.rb
CHANGED
|
@@ -1,345 +1,447 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
4
|
-
require "
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
# Hanami
|
|
10
|
-
#
|
|
11
|
-
# @since 0.1.0
|
|
3
|
+
require "dry/configurable"
|
|
4
|
+
require "zeitwerk"
|
|
5
|
+
|
|
6
|
+
require_relative "mailer/errors"
|
|
7
|
+
|
|
12
8
|
module Hanami
|
|
13
|
-
#
|
|
9
|
+
# Base mailer class
|
|
14
10
|
#
|
|
15
|
-
# @
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
#
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
11
|
+
# @api public
|
|
12
|
+
# @since 3.0.0
|
|
13
|
+
class Mailer
|
|
14
|
+
# @api private
|
|
15
|
+
def self.gem_loader
|
|
16
|
+
@gem_loader ||= Zeitwerk::Loader.new.tap do |loader|
|
|
17
|
+
root = File.expand_path("..", __dir__)
|
|
18
|
+
loader.tag = "hanami-mailer"
|
|
19
|
+
loader.push_dir(root)
|
|
20
|
+
loader.ignore(
|
|
21
|
+
"#{root}/hanami-mailer.rb",
|
|
22
|
+
"#{root}/hanami/mailer/version.rb",
|
|
23
|
+
"#{root}/hanami/mailer/errors.rb"
|
|
24
|
+
)
|
|
25
|
+
loader.inflector = Zeitwerk::GemInflector.new("#{root}/hanami-mailer.rb")
|
|
26
|
+
loader.inflector.inflect(
|
|
27
|
+
"dsl" => "DSL",
|
|
28
|
+
"smtp" => "SMTP"
|
|
29
|
+
)
|
|
31
30
|
end
|
|
32
31
|
end
|
|
33
32
|
|
|
34
|
-
|
|
35
|
-
#
|
|
36
|
-
# @since 0.1.0
|
|
37
|
-
# @api private
|
|
38
|
-
CONTENT_TYPES = {
|
|
39
|
-
html: "text/html",
|
|
40
|
-
txt: "text/plain"
|
|
41
|
-
}.freeze
|
|
33
|
+
gem_loader.setup
|
|
42
34
|
|
|
43
|
-
|
|
35
|
+
extend Dry::Configurable
|
|
44
36
|
|
|
45
|
-
#
|
|
46
|
-
# @api
|
|
47
|
-
|
|
48
|
-
|
|
37
|
+
# Paths to search for static attachment files
|
|
38
|
+
# @api public
|
|
39
|
+
# @since 3.0.0
|
|
40
|
+
setting :attachment_paths, default: []
|
|
49
41
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
# @param blk [Proc] the configuration block
|
|
54
|
-
#
|
|
55
|
-
# @since 0.1.0
|
|
56
|
-
#
|
|
57
|
-
# @see Hanami::Mailer::Configuration
|
|
42
|
+
# Include Hanami::View integration if available.
|
|
43
|
+
# This wraps initialization to provide automatic view building from exposures.
|
|
44
|
+
# The ViewIntegration module adds all view-related settings and capabilities.
|
|
58
45
|
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
46
|
+
# Attempt to require hanami-view so users don't need to worry about load order.
|
|
47
|
+
# View-building behavior can be disabled per-class via `config.integrate_view = false`.
|
|
48
|
+
begin
|
|
49
|
+
require "hanami/view"
|
|
50
|
+
rescue LoadError => exception
|
|
51
|
+
raise unless exception.path == "hanami/view"
|
|
52
|
+
end
|
|
53
|
+
if defined?(Hanami::View)
|
|
54
|
+
require_relative "mailer/view_integration"
|
|
55
|
+
include ViewIntegration
|
|
68
56
|
end
|
|
69
57
|
|
|
70
|
-
#
|
|
71
|
-
# It includes basic Hanami::Mailer modules to the given Class.
|
|
72
|
-
# It sets a copy of the framework configuration
|
|
73
|
-
#
|
|
74
|
-
# @param base [Class] the target mailer
|
|
75
|
-
#
|
|
76
|
-
# @since 0.1.0
|
|
58
|
+
# Standard email headers that have dedicated convenience methods
|
|
77
59
|
# @api private
|
|
78
|
-
|
|
79
|
-
# @see http://www.ruby-doc.org/core/Module.html#method-i-included
|
|
80
|
-
def self.included(base)
|
|
81
|
-
conf = configuration
|
|
82
|
-
conf.add_mailer(base)
|
|
60
|
+
STANDARD_HEADERS = %i[from to cc bcc reply_to return_path subject].freeze
|
|
83
61
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
62
|
+
class << self
|
|
63
|
+
# Helper method for creating Attachment objects
|
|
64
|
+
#
|
|
65
|
+
# This is a convenience method for creating Attachment objects
|
|
66
|
+
# that can be passed to the `attachments:` parameter.
|
|
67
|
+
#
|
|
68
|
+
# @param filename [String] name of the file
|
|
69
|
+
# @param content [String] file content
|
|
70
|
+
# @param options [Hash] additional options (content_type, inline, etc.)
|
|
71
|
+
#
|
|
72
|
+
# @return [Attachment] attachment object
|
|
73
|
+
#
|
|
74
|
+
# @api public
|
|
75
|
+
# @since 3.0.0
|
|
76
|
+
#
|
|
77
|
+
# @example
|
|
78
|
+
# mailer.deliver(
|
|
79
|
+
# user: user,
|
|
80
|
+
# attachments: [
|
|
81
|
+
# Hanami::Mailer.file("invoice.pdf", pdf_bytes, content_type: "application/pdf")
|
|
82
|
+
# ]
|
|
83
|
+
# )
|
|
84
|
+
def file(filename, content, content_type: nil, inline: false)
|
|
85
|
+
Attachment.new(filename:, content:, content_type:, inline:)
|
|
92
86
|
end
|
|
93
|
-
|
|
94
|
-
conf.copy!(base)
|
|
95
87
|
end
|
|
96
88
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
89
|
+
class << self
|
|
90
|
+
# Define a header field
|
|
91
|
+
#
|
|
92
|
+
# Can be called with:
|
|
93
|
+
# - A static value: `header :from, "noreply@example.com"`
|
|
94
|
+
# - A static value with proper casing: `header "X-Priority", "1"`
|
|
95
|
+
# - A proc/block: `header(:to) { |recipient| recipient[:email] }`
|
|
96
|
+
#
|
|
97
|
+
# A block's parameters follow the same convention as everywhere in the mailer:
|
|
98
|
+
#
|
|
99
|
+
# - Positional parameters receive exposure values, matched by name.
|
|
100
|
+
# - Keyword parameters receive matching keys from the `deliver` input.
|
|
101
|
+
#
|
|
102
|
+
# Header names:
|
|
103
|
+
# - Symbols with underscores (e.g., :x_priority) are converted to Title-Case (X-Priority)
|
|
104
|
+
# - Strings are passed through as-is, preserving casing
|
|
105
|
+
# - Use strings for full control over casing
|
|
106
|
+
#
|
|
107
|
+
# @param field_name [Symbol, String] the header field name
|
|
108
|
+
# @param value [Object, nil] optional static value
|
|
109
|
+
# @param block [Proc] optional block for computing the value
|
|
110
|
+
#
|
|
111
|
+
# @api public
|
|
112
|
+
# @since 3.0.0
|
|
113
|
+
def header(field_name, value = nil, &block)
|
|
114
|
+
headers.add(field_name, block, default: value)
|
|
115
|
+
end
|
|
121
116
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
117
|
+
# Define header fields: from, to, cc, bcc, reply_to, return_path, subject
|
|
118
|
+
#
|
|
119
|
+
# Each method can be called with:
|
|
120
|
+
# - A static value: `from "noreply@example.com"`
|
|
121
|
+
# - A proc/block: `to { |recipient| recipient[:email] }`
|
|
122
|
+
#
|
|
123
|
+
# As with {#header}, a block's positional parameters receive exposure values and its keyword
|
|
124
|
+
# parameters receive matching keys from the `deliver` input.
|
|
125
|
+
#
|
|
126
|
+
# @api public
|
|
127
|
+
# @since 3.0.0
|
|
128
|
+
STANDARD_HEADERS.each do |field_name|
|
|
129
|
+
define_method(field_name) do |value = nil, &block|
|
|
130
|
+
header(field_name, value, &block)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# @api private
|
|
135
|
+
def headers
|
|
136
|
+
@headers ||= DSL::Exposures.new
|
|
137
|
+
end
|
|
130
138
|
|
|
131
|
-
|
|
132
|
-
module ClassMethods
|
|
133
|
-
# Delivers a multipart email message.
|
|
139
|
+
# Defines one or more values to expose to the template.
|
|
134
140
|
#
|
|
135
|
-
#
|
|
136
|
-
# both delivered.
|
|
141
|
+
# An exposure's value comes from the first of these that applies:
|
|
137
142
|
#
|
|
138
|
-
#
|
|
139
|
-
#
|
|
143
|
+
# 1. The given block (single name only).
|
|
144
|
+
# 2. An instance method matching the name.
|
|
145
|
+
# 3. The matching key in the input given to {#call}, or the `:default`
|
|
146
|
+
# option if the input has no such key.
|
|
140
147
|
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
148
|
+
# When a block or method provides the value, its parameters determine what
|
|
149
|
+
# it receives:
|
|
143
150
|
#
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
151
|
+
# - Positional parameters receive other exposures' values, matched by name.
|
|
152
|
+
# - Keyword parameters receive matching keys from the input. Give them
|
|
153
|
+
# defaults to make those input keys optional.
|
|
154
|
+
# - A keyword splat (`**input`) receives the entire input.
|
|
147
155
|
#
|
|
148
|
-
#
|
|
156
|
+
# Pass several names to expose multiple values at once; the options then
|
|
157
|
+
# apply to every named exposure. A block may only be given for a single
|
|
158
|
+
# name.
|
|
149
159
|
#
|
|
150
|
-
# @
|
|
160
|
+
# @example A value computed by a block
|
|
161
|
+
# expose :greeting do |user:|
|
|
162
|
+
# "Hello, #{user.name}"
|
|
163
|
+
# end
|
|
151
164
|
#
|
|
152
|
-
# @example
|
|
153
|
-
#
|
|
165
|
+
# @example A value from a matching instance method, or passed through from the input
|
|
166
|
+
# expose :user
|
|
154
167
|
#
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
# end.load!
|
|
168
|
+
# @example Multiple values passed through from the input
|
|
169
|
+
# expose :user, :order
|
|
158
170
|
#
|
|
159
|
-
#
|
|
160
|
-
#
|
|
161
|
-
#
|
|
171
|
+
# @param names [Array<Symbol>] the exposure names
|
|
172
|
+
# @param options [Hash] options applied to the exposure(s)
|
|
173
|
+
# @option options [Object] :default value to use when the input has no
|
|
174
|
+
# matching key (pass-through exposures only)
|
|
175
|
+
# @option options [Boolean] :private withhold from the view, while keeping
|
|
176
|
+
# the value available as a dependency to other exposures, headers,
|
|
177
|
+
# attachments, and delivery options (defaults to false)
|
|
178
|
+
# @param block [Proc] block computing the value (single name only)
|
|
162
179
|
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
|
|
180
|
+
# @api public
|
|
181
|
+
# @since 3.0.0
|
|
182
|
+
def expose(*names, **options, &block)
|
|
183
|
+
if names.length == 1
|
|
184
|
+
exposures.add(names.first, block, **options)
|
|
185
|
+
else
|
|
186
|
+
names.each { |name| exposures.add(name, nil, **options) }
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Defines one or more private exposures.
|
|
166
191
|
#
|
|
167
|
-
#
|
|
168
|
-
#
|
|
169
|
-
#
|
|
192
|
+
# A private exposure is computed and stays available as a dependency to other exposures, and
|
|
193
|
+
# to the mailer's headers, attachments, and delivery options, but is never passed to the view
|
|
194
|
+
# for rendering. This is a shorthand for `expose(..., private: true)`.
|
|
170
195
|
#
|
|
171
|
-
#
|
|
196
|
+
# @see #expose
|
|
197
|
+
#
|
|
198
|
+
# @api public
|
|
199
|
+
# @since 3.0.0
|
|
200
|
+
def private_expose(*names, **options, &block)
|
|
201
|
+
expose(*names, **options, private: true, &block)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# @api private
|
|
205
|
+
def exposures
|
|
206
|
+
@exposures ||= DSL::Exposures.new
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# Define an attachment
|
|
172
210
|
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
211
|
+
# An attachment block returns one or more attachment objects (use the {#file} helper). As with
|
|
212
|
+
# {#header}, its positional parameters receive exposure values and its keyword parameters
|
|
213
|
+
# receive matching keys from the `deliver` input.
|
|
176
214
|
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
# end
|
|
180
|
-
# end
|
|
181
|
-
# end
|
|
215
|
+
# @param name_or_filename [Symbol, String] method name or static filename
|
|
216
|
+
# @param proc [Proc] optional block for computing attachment
|
|
182
217
|
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
218
|
+
# @api public
|
|
219
|
+
# @since 3.0.0
|
|
220
|
+
def attachment(name_or_filename = nil, **options, &block)
|
|
221
|
+
attachments.add(name_or_filename, block, **options)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# @api private
|
|
225
|
+
def attachments
|
|
226
|
+
@attachments ||= DSL::Attachments.new
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Define a delivery option
|
|
185
230
|
#
|
|
186
|
-
#
|
|
187
|
-
#
|
|
231
|
+
# Delivery options are delivery-method-specific parameters that can be used
|
|
232
|
+
# to customize how a message is sent. For example, a third-party email service
|
|
233
|
+
# might support scheduled sending, priority levels, or tracking options.
|
|
188
234
|
#
|
|
189
|
-
#
|
|
190
|
-
#
|
|
235
|
+
# As with {#header}, a block's positional parameters receive exposure values and its keyword
|
|
236
|
+
# parameters receive matching keys from the `deliver` input.
|
|
191
237
|
#
|
|
192
|
-
#
|
|
193
|
-
#
|
|
238
|
+
# @param name [Symbol] the option name
|
|
239
|
+
# @param value [Object, nil] optional static value
|
|
240
|
+
# @param block [Proc] optional block for computing the value
|
|
194
241
|
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
|
|
198
|
-
|
|
242
|
+
# @api public
|
|
243
|
+
# @since 3.0.0
|
|
244
|
+
#
|
|
245
|
+
# @example Static value
|
|
246
|
+
# delivery_option :track_opens, true
|
|
247
|
+
#
|
|
248
|
+
# @example Value computed from the input (keyword parameter)
|
|
249
|
+
# delivery_option :send_at do |scheduled_time:|
|
|
250
|
+
# scheduled_time
|
|
251
|
+
# end
|
|
252
|
+
#
|
|
253
|
+
# @example Value computed from an exposure (positional parameter)
|
|
254
|
+
# delivery_option :priority do |user_type|
|
|
255
|
+
# user_type == "premium" ? "high" : "normal"
|
|
256
|
+
# end
|
|
257
|
+
def delivery_option(name, value = nil, &block)
|
|
258
|
+
delivery_options.add(name, block, default: value)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# @api private
|
|
262
|
+
def delivery_options
|
|
263
|
+
@delivery_options ||= DSL::Exposures.new
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# @api private
|
|
267
|
+
def inherited(subclass)
|
|
268
|
+
super
|
|
269
|
+
|
|
270
|
+
subclass.instance_variable_set(:@headers, headers.dup)
|
|
271
|
+
subclass.instance_variable_set(:@exposures, exposures.dup)
|
|
272
|
+
subclass.instance_variable_set(:@attachments, attachments.dup)
|
|
273
|
+
subclass.instance_variable_set(:@delivery_options, delivery_options.dup)
|
|
199
274
|
end
|
|
200
275
|
end
|
|
201
276
|
|
|
202
|
-
#
|
|
277
|
+
# @api private
|
|
278
|
+
attr_reader :view, :delivery_method
|
|
279
|
+
|
|
280
|
+
# Initialize a new mailer instance
|
|
203
281
|
#
|
|
204
|
-
# @param
|
|
205
|
-
# @
|
|
206
|
-
# @option :charset [String] charset
|
|
282
|
+
# @param view [Object, nil] optional view object for rendering
|
|
283
|
+
# @param delivery_method [Object] delivery method (defaults to Test delivery)
|
|
207
284
|
#
|
|
208
|
-
# @
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
@
|
|
212
|
-
@
|
|
213
|
-
@mail = build
|
|
214
|
-
prepare
|
|
285
|
+
# @api public
|
|
286
|
+
# @since 3.0.0
|
|
287
|
+
def initialize(view: nil, delivery_method: nil)
|
|
288
|
+
@view = view
|
|
289
|
+
@delivery_method = delivery_method || default_delivery_method
|
|
215
290
|
end
|
|
216
291
|
|
|
217
|
-
#
|
|
292
|
+
# Deliver the email
|
|
218
293
|
#
|
|
219
|
-
# @param
|
|
294
|
+
# @param headers [Hash] optional header overrides (from, to, cc, bcc, reply_to, return_path, subject)
|
|
295
|
+
# @param attachments [Array<Hash, Attachment>, nil] optional runtime attachments
|
|
296
|
+
# @param format [Symbol, nil] optional format to render (:html or :text)
|
|
297
|
+
# @param input [Hash] input data for exposures and rendering
|
|
220
298
|
#
|
|
221
|
-
# @return [
|
|
299
|
+
# @return [Delivery::Result]
|
|
222
300
|
#
|
|
223
|
-
# @
|
|
224
|
-
# @
|
|
225
|
-
def
|
|
226
|
-
|
|
301
|
+
# @api public
|
|
302
|
+
# @since 3.0.0
|
|
303
|
+
def deliver(headers: {}, attachments: nil, format: nil, **input)
|
|
304
|
+
message = prepare(headers:, attachments:, format:, **input)
|
|
305
|
+
delivery_method.call(message)
|
|
227
306
|
end
|
|
228
307
|
|
|
229
|
-
#
|
|
308
|
+
# Previews the email without delivering it
|
|
230
309
|
#
|
|
231
|
-
#
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
mail.deliver
|
|
235
|
-
rescue ArgumentError => exception
|
|
236
|
-
raise MissingDeliveryDataError if exception.message =~ /SMTP (From|To) address/
|
|
237
|
-
|
|
238
|
-
raise
|
|
239
|
-
end
|
|
240
|
-
|
|
241
|
-
protected
|
|
242
|
-
|
|
243
|
-
# Prepare the email message when a new mailer is initialized.
|
|
310
|
+
# Builds the message and passes it to the delivery method's `preview` hook, returning whatever
|
|
311
|
+
# that returns. The default (and test) delivery method returns the message unchanged; a
|
|
312
|
+
# third-party delivery method can override `preview` to apply service-specific logic.
|
|
244
313
|
#
|
|
245
|
-
#
|
|
314
|
+
# @param headers [Hash] optional header overrides (from, to, cc, bcc, reply_to, return_path, subject)
|
|
315
|
+
# @param attachments [Array<Hash, Attachment>, nil] optional runtime attachments
|
|
316
|
+
# @param format [Symbol, nil] optional format to render (:html or :text)
|
|
317
|
+
# @param input [Hash] input data for exposures and rendering
|
|
246
318
|
#
|
|
247
|
-
# @
|
|
319
|
+
# @return [Message]
|
|
248
320
|
#
|
|
249
|
-
# @
|
|
250
|
-
#
|
|
321
|
+
# @api public
|
|
322
|
+
# @since 3.0.0
|
|
323
|
+
def preview(headers: {}, attachments: nil, format: nil, **input)
|
|
324
|
+
message = prepare(headers:, attachments:, format:, **input)
|
|
325
|
+
delivery_method.preview(message)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# rubocop:disable Metrics/AbcSize
|
|
329
|
+
|
|
330
|
+
# Build the message without delivering it
|
|
331
|
+
#
|
|
332
|
+
# @param headers [Hash] optional header overrides (from, to, cc, bcc, reply_to, return_path, subject)
|
|
333
|
+
# @param attachments [Array<Hash, Attachment>, nil] optional runtime attachments
|
|
334
|
+
# @param format [Symbol, nil] optional format to render (:html or :text)
|
|
335
|
+
# @param input [Hash] input data for exposures and rendering
|
|
336
|
+
#
|
|
337
|
+
# @return [Message]
|
|
338
|
+
#
|
|
339
|
+
# @api public
|
|
340
|
+
# @since 3.0.0
|
|
341
|
+
def prepare(headers: {}, attachments: nil, format: nil, **input)
|
|
342
|
+
# Evaluate exposures as our "locals". These will be provided as the _depdenencies_ (available
|
|
343
|
+
# via positional params) to all our other class-level exposure-like APIs: headers,
|
|
344
|
+
# attachments, and delivery options.
|
|
345
|
+
locals = self.class.exposures.bind(self).call(input)
|
|
346
|
+
|
|
347
|
+
# Evaluate class-level headers, giving precdence to headers given as explicit arguments.
|
|
348
|
+
header_overrides = headers.compact
|
|
349
|
+
headers = self.class.headers
|
|
350
|
+
.bind(self)
|
|
351
|
+
.call(input, dependencies: locals)
|
|
352
|
+
.merge(header_overrides)
|
|
353
|
+
|
|
354
|
+
# Extract custom headers and normalize their header names to proper casing.
|
|
355
|
+
custom_headers = headers
|
|
356
|
+
.reject { |key, _| STANDARD_HEADERS.include?(key) }
|
|
357
|
+
.transform_keys { |key| normalize_header_name(key) }
|
|
358
|
+
|
|
359
|
+
# Render bodies. Private exposures are available to the methods above as dependencies, but are
|
|
360
|
+
# withheld from the view.
|
|
361
|
+
html_body, text_body = render(self.class.exposures.reject_private(locals), format:)
|
|
362
|
+
|
|
363
|
+
# Evaluate class-level attachments and merge with runtime attachments.
|
|
364
|
+
runtime_attachments = attachments
|
|
365
|
+
attachments = self.class.attachments
|
|
366
|
+
.bind(self)
|
|
367
|
+
.call(input, dependencies: locals)
|
|
368
|
+
.concat(runtime_attachments)
|
|
369
|
+
.to_a
|
|
370
|
+
|
|
371
|
+
# Evaluate delivery options.
|
|
372
|
+
delivery_options = self.class.delivery_options.bind(self).call(input, dependencies: locals)
|
|
373
|
+
|
|
374
|
+
# Build message
|
|
375
|
+
Message.new(
|
|
376
|
+
from: headers[:from],
|
|
377
|
+
to: headers[:to],
|
|
378
|
+
cc: headers[:cc],
|
|
379
|
+
bcc: headers[:bcc],
|
|
380
|
+
reply_to: headers[:reply_to],
|
|
381
|
+
return_path: headers[:return_path],
|
|
382
|
+
subject: headers[:subject],
|
|
383
|
+
html_body:,
|
|
384
|
+
text_body:,
|
|
385
|
+
attachments: attachments,
|
|
386
|
+
headers: custom_headers,
|
|
387
|
+
delivery_options:
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
# rubocop:enable Metrics/AbcSize
|
|
391
|
+
|
|
392
|
+
# Helper method for creating attachments in attachment blocks
|
|
251
393
|
#
|
|
252
|
-
#
|
|
253
|
-
#
|
|
254
|
-
# include Hanami::Mailer
|
|
394
|
+
# Returns an Attachment object that provides a structured, validated
|
|
395
|
+
# way to define attachment data instead of using raw hashes.
|
|
255
396
|
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
397
|
+
# @param filename [String] name of the file
|
|
398
|
+
# @param content [String] file content
|
|
399
|
+
# @param options [Hash] additional options (content_type, inline, etc.)
|
|
259
400
|
#
|
|
260
|
-
#
|
|
261
|
-
# mail.attachments['invoice.pdf'] = File.read('/path/to/invoice.pdf')
|
|
262
|
-
# end
|
|
401
|
+
# @return [Attachment] attachment object
|
|
263
402
|
#
|
|
264
|
-
#
|
|
403
|
+
# @api public
|
|
404
|
+
# @since 3.0.0
|
|
265
405
|
#
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
# end
|
|
406
|
+
# @example
|
|
407
|
+
# attachment :invoice do |invoice:|
|
|
408
|
+
# file("invoice-#{invoice.number}.pdf", invoice.to_pdf, content_type: "application/pdf")
|
|
270
409
|
# end
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
# user = User.new(name: 'L', email: 'user@example.com')
|
|
274
|
-
def prepare
|
|
410
|
+
def file(...)
|
|
411
|
+
self.class.file(...)
|
|
275
412
|
end
|
|
276
413
|
|
|
277
|
-
# @api private
|
|
278
|
-
# @since 0.1.0
|
|
279
|
-
def method_missing(method_name)
|
|
280
|
-
@locals.fetch(method_name) { super }
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
# @since 0.1.0
|
|
284
|
-
attr_reader :mail
|
|
285
|
-
|
|
286
|
-
# @api private
|
|
287
|
-
# @since 0.1.0
|
|
288
|
-
attr_reader :charset
|
|
289
|
-
|
|
290
414
|
private
|
|
291
415
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
m.cc = __dsl(:cc)
|
|
298
|
-
m.bcc = __dsl(:bcc)
|
|
299
|
-
m.reply_to = __dsl(:reply_to)
|
|
300
|
-
m.subject = __dsl(:subject)
|
|
301
|
-
|
|
302
|
-
m.charset = charset
|
|
303
|
-
m.html_part = __part(:html)
|
|
304
|
-
m.text_part = __part(:txt)
|
|
305
|
-
|
|
306
|
-
m.delivery_method(*Hanami::Mailer.configuration.delivery_method)
|
|
307
|
-
end
|
|
416
|
+
# Renders and returns HTML and text bodies.
|
|
417
|
+
def render(input, format: nil)
|
|
418
|
+
html_body = render_view(:html, input) if format.nil? || format == :html
|
|
419
|
+
text_body = render_view(:text, input) if format.nil? || format == :text
|
|
420
|
+
[html_body, text_body]
|
|
308
421
|
end
|
|
309
422
|
|
|
310
|
-
#
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
case result = self.class.__send__(method_name)
|
|
314
|
-
when Symbol
|
|
315
|
-
__send__(result)
|
|
316
|
-
else
|
|
317
|
-
result
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
|
|
321
|
-
# @api private
|
|
322
|
-
# @since 0.1.0
|
|
323
|
-
def __part(format)
|
|
324
|
-
return unless __part?(format)
|
|
423
|
+
# Renders body for a specific format.
|
|
424
|
+
def render_view(format, input)
|
|
425
|
+
return unless view
|
|
325
426
|
|
|
326
|
-
|
|
327
|
-
part.content_type = "#{CONTENT_TYPES.fetch(format)}; charset=#{charset}"
|
|
328
|
-
part.body = render(format)
|
|
329
|
-
end
|
|
427
|
+
view.call(format:, **input).to_s
|
|
330
428
|
end
|
|
331
429
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def __part?(format)
|
|
335
|
-
@format == format ||
|
|
336
|
-
(!@format && !self.class.templates(format).nil?)
|
|
430
|
+
def default_delivery_method
|
|
431
|
+
Delivery::Test.new
|
|
337
432
|
end
|
|
338
433
|
|
|
339
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
434
|
+
# Normalizes header names to proper email header casing.
|
|
435
|
+
def normalize_header_name(name)
|
|
436
|
+
return name if name.is_a?(String)
|
|
437
|
+
|
|
438
|
+
# Convert symbol to string and apply Title-Case with dashes
|
|
439
|
+
# e.g., :x_priority => "X-Priority"
|
|
440
|
+
# :list_unsubscribe => "List-Unsubscribe"
|
|
441
|
+
name.to_s
|
|
442
|
+
.split("_")
|
|
443
|
+
.map(&:capitalize)
|
|
444
|
+
.join("-")
|
|
343
445
|
end
|
|
344
446
|
end
|
|
345
447
|
end
|