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.
data/lib/hanami/mailer.rb CHANGED
@@ -1,345 +1,447 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "hanami/utils/class_attribute"
4
- require "hanami/mailer/version"
5
- require "hanami/mailer/configuration"
6
- require "hanami/mailer/dsl"
7
- require "mail"
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
- # Hanami::Mailer
9
+ # Base mailer class
14
10
  #
15
- # @since 0.1.0
16
- module Mailer
17
- # Base error for Hanami::Mailer
18
- #
19
- # @since 0.1.0
20
- class Error < ::StandardError
21
- end
22
-
23
- # Missing delivery data error
24
- #
25
- # It's raised when a mailer doesn't specify <tt>from</tt> or <tt>to</tt>.
26
- #
27
- # @since 0.1.0
28
- class MissingDeliveryDataError < Error
29
- def initialize
30
- super("Missing delivery data, please check 'from', or 'to'")
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
- # Content types mapping
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
- include Utils::ClassAttribute
35
+ extend Dry::Configurable
44
36
 
45
- # @since 0.1.0
46
- # @api private
47
- class_attribute :configuration
48
- self.configuration = Configuration.new
37
+ # Paths to search for static attachment files
38
+ # @api public
39
+ # @since 3.0.0
40
+ setting :attachment_paths, default: []
49
41
 
50
- # Configure the framework.
51
- # It yields the given block in the context of the configuration
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
- # @example
60
- # require 'hanami/mailer'
61
- #
62
- # Hanami::Mailer.configure do
63
- # root '/path/to/root'
64
- # end
65
- def self.configure(&blk)
66
- configuration.instance_eval(&blk)
67
- self
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
- # Override Ruby's hook for modules.
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
- base.class_eval do
85
- extend Dsl
86
- extend ClassMethods
87
-
88
- include Utils::ClassAttribute
89
- class_attribute :configuration
90
-
91
- self.configuration = conf.duplicate
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
- # Test deliveries
98
- #
99
- # This is a collection of delivered messages, used when <tt>delivery_method</tt>
100
- # is set on <tt>:test</tt>
101
- #
102
- # @return [Array] a collection of delivered messages
103
- #
104
- # @since 0.1.0
105
- #
106
- # @see Hanami::Mailer::Configuration#delivery_mode
107
- #
108
- # @example
109
- # require 'hanami/mailer'
110
- #
111
- # Hanami::Mailer.configure do
112
- # delivery_method :test
113
- # end.load!
114
- #
115
- # # In testing code
116
- # Signup::Welcome.deliver
117
- # Hanami::Mailer.deliveries.count # => 1
118
- def self.deliveries
119
- Mail::TestMailer.deliveries
120
- end
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
- # Load the framework
123
- #
124
- # @since 0.1.0
125
- # @api private
126
- def self.load!
127
- Mail.eager_autoload!
128
- configuration.load!
129
- end
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
- # @since 0.1.0
132
- module ClassMethods
133
- # Delivers a multipart email message.
139
+ # Defines one or more values to expose to the template.
134
140
  #
135
- # When a mailer defines a <tt>html</tt> and <tt>txt</tt> template, they are
136
- # both delivered.
141
+ # An exposure's value comes from the first of these that applies:
137
142
  #
138
- # In order to selectively deliver only one of the two templates, use
139
- # <tt>Signup::Welcome.deliver(format: :txt)</tt>
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
- # All the given locals, excepted the reserved ones (<tt>:format</tt> and
142
- # <tt>charset</tt>), are available as rendering context for the templates.
148
+ # When a block or method provides the value, its parameters determine what
149
+ # it receives:
143
150
  #
144
- # @param locals [Hash] a set of objects that acts as context for the rendering
145
- # @option :format [Symbol] specify format to deliver
146
- # @option :charset [String] charset
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
- # @since 0.1.0
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
- # @see Hanami::Mailer::Configuration#default_charset
160
+ # @example A value computed by a block
161
+ # expose :greeting do |user:|
162
+ # "Hello, #{user.name}"
163
+ # end
151
164
  #
152
- # @example
153
- # require 'hanami/mailer'
165
+ # @example A value from a matching instance method, or passed through from the input
166
+ # expose :user
154
167
  #
155
- # Hanami::Mailer.configure do
156
- # delivery_method :smtp
157
- # end.load!
168
+ # @example Multiple values passed through from the input
169
+ # expose :user, :order
158
170
  #
159
- # module Billing
160
- # class Invoice
161
- # include Hanami::Mailer
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
- # from 'noreply@example.com'
164
- # to :recipient
165
- # subject :subject_line
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
- # def prepare
168
- # mail.attachments['invoice.pdf'] = File.read('/path/to/invoice.pdf')
169
- # end
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
- # private
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
- # def recipient
174
- # user.email
175
- # end
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
- # def subject_line
178
- # "Invoice - #{ invoice.number }"
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
- # invoice = Invoice.new
184
- # user = User.new(name: 'L', email: 'user@example.com')
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
- # # Deliver both text, HTML parts and the attachment
187
- # Billing::Invoice.deliver(invoice: invoice, user: user)
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
- # # Deliver only the text part and the attachment
190
- # Billing::Invoice.deliver(invoice: invoice, user: user, format: :txt)
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
- # # Deliver only the text part and the attachment
193
- # Billing::Invoice.deliver(invoice: invoice, user: user, format: :html)
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
- # # Deliver both the parts with "iso-8859"
196
- # Billing::Invoice.deliver(invoice: invoice, user: user, charset: "iso-8859")
197
- def deliver(locals = {})
198
- new(locals).deliver
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
- # Initialize a mailer
277
+ # @api private
278
+ attr_reader :view, :delivery_method
279
+
280
+ # Initialize a new mailer instance
203
281
  #
204
- # @param locals [Hash] a set of objects that acts as context for the rendering
205
- # @option :format [Symbol] specify format to deliver
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
- # @since 0.1.0
209
- def initialize(locals = {})
210
- @locals = locals
211
- @format = locals.fetch(:format, nil)
212
- @charset = locals.fetch(:charset, self.class.configuration.default_charset)
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
- # Render a single template with the specified format.
292
+ # Deliver the email
218
293
  #
219
- # @param format [Symbol] format
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 [String] the output of the rendering process.
299
+ # @return [Delivery::Result]
222
300
  #
223
- # @since 0.1.0
224
- # @api private
225
- def render(format)
226
- self.class.templates(format).render(self, @locals)
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
- # Delivers a multipart email, by looking at all the associated templates and render them.
308
+ # Previews the email without delivering it
230
309
  #
231
- # @since 0.1.0
232
- # @api private
233
- def deliver
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
- # This is a hook that can be overwritten by mailers.
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
- # @since 0.1.0
319
+ # @return [Message]
248
320
  #
249
- # @example
250
- # require 'hanami/mailer'
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
- # module Billing
253
- # class Invoice
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
- # subject 'Invoice'
257
- # from 'noreply@example.com'
258
- # to ''
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
- # def prepare
261
- # mail.attachments['invoice.pdf'] = File.read('/path/to/invoice.pdf')
262
- # end
401
+ # @return [Attachment] attachment object
263
402
  #
264
- # private
403
+ # @api public
404
+ # @since 3.0.0
265
405
  #
266
- # def recipient
267
- # user.email
268
- # end
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
- # invoice = Invoice.new
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
- def build
293
- Mail.new.tap do |m|
294
- m.return_path = __dsl(:return_path)
295
- m.from = __dsl(:from)
296
- m.to = __dsl(:to)
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
- # @api private
311
- # @since 0.1.0
312
- def __dsl(method_name)
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
- Mail::Part.new.tap do |part|
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
- # @api private
333
- # @since 0.1.0
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
- # @api private
340
- # @since 0.4.0
341
- def respond_to_missing?(method_name, _include_all)
342
- @locals.key?(method_name)
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